docs(lean4): rev 7 — round 6 review, five P1s, and reconcile the ledgers

The 4a/4b split held; five P1s against rev 6's own content, all real,
all reproduced. Four share a root: rev 6 verified its external facts and
under-verified its internal ones.

1. Stage 4a's declared footprint excluded the tests its own acceptance
   required. 46a-46e cannot live in tests/auto_pair_acceptance.rs,
   which criterion 46 requires byte-identical. Footprint now names
   tests/typed_edit_chain_acceptance.rs and gates on it.

2. Pending abbreviation state had the wrong owner. pmacs is
   multi-frontend: EditorCore.views is per-FrontendId with its own
   active window, take_typed_edit is already frontend-keyed, and
   buffer.after-switch fires with no arguments — so a buffer-keyed
   clear-on-switch lets any frontend discard another's pending
   abbreviation. Now keyed (frontend, buffer) with a window check,
   frontend-scoped clearing, a frontend.detached purge, and acceptance
   45i, which the buffer-keyed design passes every other criterion
   without.

3. The shortest-match rule was missing its tie-break: upstream keeps
   declaration order among equal-length shortest keys, and 101 prefixes
   have equal-shortest candidates resolving to different symbols (f
   picks f< over f>). A pairs-iterated Lua map cannot express this, so
   the vendored artifact is now an ordered sequence and resolution sorts
   by (#key, source rank). Rev 6 missed this because it declared the
   package ships no README after a 404 on the package root, with the
   directory listing showing src/README.md already in hand — a 404 on a
   guessed path is not evidence of absence, and the README states the
   rule in one sentence.

4. The generator's rejection rule rejected the current table: \ is a key
   and " begins eleven, while acceptance 45d requires \ to work.
   Replaced with canonical lossless escaping; aborts only on duplicate
   keys, invalid UTF-8, and a failed self-round-trip. 45g no longer
   claims to diff against abbreviations.json, which is not shipped.

5. Durable and volatile state were not reconciled. agent-handoff.md
   anchored main at d152120 with neither #167 nor #170 and no Lean arc
   bullet at all; active-work.md kept 407 lines of merged Stage 1/2/3a/3b
   history against its own instruction to prune merged entries, under a
   stale snapshot date. Durable facts moved to the handoff; the ledger
   keeps only the unlanded Stage 4 lane.

Also corrected: 119 multi-codepoint symbols (26 with $CURSOR), not 93;
three backslash values, not two; Q#LN22 now states the terminating-\
reprocess rule acceptance 45d depended on; acceptance 38 says the
terminator is retained, so undo restores "\alpha " with its space;
coherence cites golden-journey step 5, not step 4; and the
config-registry prior art points at Q#LN22.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B
This commit is contained in:
Levi Neuwirth 2026-07-26 10:39:01 -04:00
parent 3b54c78494
commit c4fad0731c
3 changed files with 371 additions and 459 deletions

View File

@ -1,6 +1,6 @@
# Active work — cross-machine resume ledger
**Snapshot: 2026-07-25.** This file records volatile work that has not
**Snapshot: 2026-07-26.** This file records volatile work that has not
landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
entries when their PR merges; do not let this become a second permanent
backlog.
@ -59,421 +59,40 @@ If it does not, stop and repair the remote/fetch configuration.
## Lean 4 lane (Arc 8) — Stages 1, 2, 3a, 3b MERGED; Stage 4 IN FRAMING
- Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review
round, all twelve checks green). Branch `githubsucks/lean4-stage1`
retained; it was worked in the shared checkout (no sibling worktree).
- Approved framing: `docs/lean4-mode-framing.md` revision 4, committed as
the branch's first commit (`a382965`) after three review rounds. **Seven
stages**, 19 decisions (Q#LN119), 64 acceptance criteria. North star:
match or exceed VS Code's Lean support.
- **Stage 1 implemented; no wire change (protocol stays v20), no LSP, no
frontend change.** Four commits: framing, grammar, theme captures,
editing surface + acceptance.
- `Cargo.toml` + `src/syntax.rs`: `arborium-lean` 2.18 and one
`BUILTIN_LANGUAGES` entry named **`lean4`** (Q#LN2 — the name becomes
the `didOpen` language_id), claiming `.lean` only.
- `src/highlight.rs`: four capture entries — `constructor`, `character`,
`keyword.conditional`, `warning`.
- `builtin/runtime/{comment,pair,syntax}.lua`: `--` comments, the
`⟨⟩ ⦃⦄ ⟮⟯` pair set, the `lean``lean4` modeline alias.
- `tests/lean4_stage1_acceptance.rs` plus unit tests in `syntax.rs` /
`highlight.rs`: 12 criteria, 17 tests.
- **Q#LN1's open obligation is discharged.** `tree-sitter-lean4` is
unusable (depends on `tree-sitter ^0.25` directly against our 0.26,
exports no `LANGUAGE` const despite its README, packages no queries);
`arborium-lean` rides `tree-sitter-language 0.1` with a pre-generated
ABI-15 parser. `cargo tree -d` shows no duplicate core. The parse smoke
pins the failure mode that matters: `→`/`∀`/`≥` must produce
`(arrow)`/`(forall)`/`(comparison)`, since a mismatched-core build
degrades silently on exactly those characters rather than failing loudly.
- **Q#LN4 is a deliberate retro-paint of seven language entries**, not
four: `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated
base-first into javascriptreact/typescript/typescriptreact. Its shape is
"every capitalized identifier" (`#match? "^[A-Z]"`) plus every Lua table
brace — not "constructors". Pinned in both directions per #146.
- Implementation findings not in the framing:
- `warning` had to move from bold red to bold **bright** red: `number`
is plain `fg(1)`, so `sorry` and an adjacent numeric literal were the
same colour. Found by writing the test.
- `Some(1)` is **not** `@constructor` — in call position a narrower
`@function` pattern wins. Only bare or pattern-position capitalized
identifiers reach it. Pinned so the blast-radius claim stays honest.
- Lean node kinds nest: `module > declaration > def|theorem`.
- `pmacs.parse.injection_aliases` is a documented **write-only** Lua
proxy (canonical map is Rust-side), so fence tests must drive
`_parse_now` and inspect layer languages, never read the table back.
- **Review round 1 addressed.** The finding: acc12's server-list assertion
could not fail for the regression it named — the shared `editor()`
helper wipes `pmacs.lsp.config` before any buffer opens, so
`#pmacs.lsp.list() == 0` holds for every language regardless of what
Stage 1 ships. It now asserts against a **pristine** `EditorState` that
`pmacs.lsp.config.lean4` is nil, with a non-vacuity check that the same
lookup finds `rust`; bite-verified by adding a `lean4` config to
`lsp.lua` and watching it fail. Also fixed a stale column in a
`highlight.rs` comment.
- Verification on this branch: `cargo fmt --check` clean; strict workspace
Clippy clean; 1,826 default + 2,003 CRDT library tests; lean4 Stage 1
9/9; comment toggle 14; auto-pair 45; injection 4; M4 121; required GPU
152; **isolated-config workspace sweep 3,150 across 90 suites**;
`git diff --check` clean. The sweep needs an isolated `XDG_CONFIG_HOME`
for the reason recorded in the bottom-panel lane below.
### Stage 2 — multi-root LSP server affinity (Q#LN15)
- **Stages 1, 2, 3a and 3b are MERGED**#160 (`main` @ `0827dd1`),
#161 (`46a1b8f`), #167 (`6f348c9`), #170 (`d400f30`). Their full
histories were pruned from this ledger in round 6, per this file's own
instruction to remove entries when their PR merges; the durable facts
now live in `docs/agent-handoff.md` §1's Lean 4 bullet, which is where
a fresh machine should read them. `docs/lean4-mode-framing.md` rev 7
carries the decisions.
- Portable branch: `githubsucks/lsp-multi-root-affinity`, shared checkout,
based on `githubsucks/main` @ `0827dd1`. Named for the substrate, not
for Lean: **the diff contains no Lean content**, because `ensure_server`
is the one server-affinity function every LSP language shares and a
cross-cutting change to it must not be reviewable only as a Lean
feature.
- Three files, no protocol change: `src/lua_bindings/mod.rs` (the
`lsp.list()` row builder gains `root_uri` + `cwd`),
`builtin/runtime/lsp.lua` (`project_root_for` returns `root, source`;
`ensure_server` hoists it above the reuse loop and matches on it),
`tests/lsp_multi_root_acceptance.rs` (9 tests, acceptance 1321).
- **The rule that keeps this from regressing every other language: the
affinity key is the root only when a root was actually FOUND.**
`project_root_for` never returns nil for a file with a path — its last
resort is the file's own directory — so a naive `(language_id, root)`
key gives every directory of loose scratch files its own server, for
every language. `source` is `"config" | "detected" | "fallback"` and
only the first two become a key.
- **Wire-identical for the fallback case, and that is provable rather
than hoped.** Matching is on the spawned spec's `root_uri` (nil matching
nil), so the fallback spawn passes `root_uri = nil`; `cwd` still carries
the directory and `build_initialize` derives the identical `rootUri`
from `cwd` when the field is None, using a percent-encoder with the same
allowed set as Lua's `file_uri_for`. `build_initialize` (`src/lsp.rs`)
is the **only** reader of `spec.root_uri` in the tree.
- Deliberate behavior change, asserted not discovered: a server
hand-spawned from `init.lua` with only `cwd` set also reads back nil, so
a root-bearing attach will not adopt it.
- `config[language].root` may now be a `function(path) -> string|nil`,
memoized per directory — needed because the hoist puts root resolution
on every attach rather than every spawn. The memo is keyed **weakly by
the resolver function itself**, so replacing `config[lang].root` cannot
serve a root the previous resolver computed. This is Q#LN8's
generalization landing early; the Lean resolver that uses it is Stage 3.
- Bite-verified three ways: 5/9 fail against the pre-change `lsp.lua`,
8/9 against the pre-change `mod.rs`, and — the one that matters most —
installing the naive always-key-on-root variant fails acceptance 20 and
21 exactly as Q#LN15 part 2 predicts. The four that survive the first
bite (13, 15, 16, 19) are the regression pins; passing on both sides is
their job.
- Every fixture sets `pmacs.project.set_search_boundary` at its own
tempdir root. Without it the marker walk climbs to the filesystem root
and a stray `.git` above the temp directory turns the markerless cases
into detected ones — the assertions would still pass while testing
nothing.
- **Found but not fixed here (pre-existing, own lane):** `ensure_server`
never forwards `cfg.restart` to `pmacs.lsp.spawn`, so a
`restart = "never"` in `pmacs.lsp.config[lang]` is silently dropped on
the auto-attach path. At least one existing test sets it believing it
takes effect. Out of scope for a PR whose acceptance 16 pins existing
attach behavior as unchanged.
- **Review round 1 addressed.** The blocker was process, not design: the
test file was committed *before* `cargo fmt` ran, so the fix sat
uncommitted in the working tree and the branch as pushed failed the
first gate. The reported "fmt clean" described the worktree, not the
branch — gate results are only meaningful when run against the pushed
tree. Also added the two pins review asked for (a **string** `config
.root` as an affinity key — acc17 only covered the function form; and
`root = false` reading as unset), each bite-verified against exactly
the mutation it targets and neither against the other. And documented
the canonicalization obligation: the `"detected"` arm is canonicalized
for free, a **configured** root is not, so on macOS a resolver
returning `/var/…` and a detected `/private/var/…` are different keys
for one directory. Stage 3's Lean resolver is the first real consumer,
so the obligation is written at the point of use.
- Verification on this branch: `cargo fmt --check` clean; strict
workspace Clippy clean; 1,826 default + 2,003 CRDT library tests;
multi-root 11/11; M4 121; statusline 7; completion popup 9; auto-pair
45; required GPU 155; **isolated-config workspace sweep 3,164 across 91
suites**; `git diff --check` clean. The sweep needs an isolated
`XDG_CONFIG_HOME` and `-- --skip basedpyright`.
### Stage 3a — dispatch seams + `pmacs.fs.canonicalize` — MERGED #167 (`main` @ `6f348c9`)
- Worktree `../pmacs-lean-stage3`, branched off `githubsucks/main` @
`46a1b8f`. Carries framing **rev 5** (the Stage 3 split) as its first
two commits, then the implementation, then a bite-driven correction.
- **Stage 2 merged as #161** (`main` @ `46a1b8f`, 2026-07-25, two review
rounds). COHERENCE.md §7 records the slice; §1.2 records the dead
`pmacs.error` channel found landing it.
- **Framing rev 5 splits Stage 3 into 3a and 3b** because rev 4 broke its
own §4 rule — the row read "two `lsp.lua` generalizations" under prose
claiming Stage 3 was Lean-only. One generalization shipped as Stage 2;
the other (Q#LN9's seams) is the shared event drain, so it is now its
own substrate stage. 3a and 3b are **strictly sequential** — 3b's
subscriber is written against 3a's seam and both touch `lsp.lua`.
- Ships: `pmacs.lsp.on_notification` / `on_response`, two arms in
`handle_server_requests`, a pending-response purge, and
`pmacs.fs.canonicalize` (Q#LN20). No protocol change, no Lean content.
- **Two framing claims were corrected during implementation**, both
recorded in §0.1 finding 6 and in the round-2 commit:
1. The reachable leak is **not** a killed buffer. The Rust core fires
exactly five hooks (`buffer.after-edit`, `buffer.after-load`,
`buffer.after-switch`, `frontend.detached`, `process.after-tick`) —
**there is no buffer-kill hook**, so nothing tears an attachment
down and the drain keeps reaching that server. The real path is
`attach_buffer` dropping a dead sid from `attachments` and
rebuilding against a fresh server, which makes `crashed`/`stopped`
the event *least* likely to be drained. Hence the purge polls
`pmacs.lsp.list()` rather than riding the drain.
2. Acceptance 32 does **not** pin "removed before invocation" —
`pcall` catches the raise either way, so before/after is
unobservable without a re-entrant drain. It pins removal being
**unconditional**; renamed accordingly.
- **`pmacs._fs` is installed from `install_async`, not `install_project`**,
purely for load order: `make_workspace` runs *after* `fs.lua` is
evaluated, so a canonicalizer placed there reads nil. This cost one
failing run to discover and is the kind of thing to check first.
- Bites recorded (all against the committed tree): removal gated on a
clean return → acc32 fails 2 != 1; an event-driven purge → the
no-attachment case fails "never called" while the attached case still
passes; a resolver without `canonicalize` → two servers (34b's own
falsification, which ships as a test).
- **Known unpinned:** the purge's generation (`attempt`) check. Reaching
it needs a crash *and* its restart to fall in a gap with no
`_async.tick`; the backoff is 500ms, so any tick sees `crashed` first
and the absent-or-terminal arm fires. Labelled as defensive in the
code rather than left looking covered.
- Verification on this branch: `cargo fmt --check` clean; strict
workspace Clippy clean; 1,826 default + 2,003 CRDT library tests;
dispatch seams 15/15 on Linux (14 on macOS — see below); multi-root
13/13; M4 121; required GPU 155; **isolated-config workspace sweep
3,189 across 93 suites, zero failures**; `git diff --check` clean.
- **Two flakes/portability facts from CI round 1, both worth keeping:**
1. `composition_overhead_under_ten_percent` tripped once in a local
sweep at 18.8% against a 10% budget, then passed 3/3 in isolation
here, passed in isolation on main, and passed a full sweep rerun.
The tell is in its own output: the same run reported realistic-frame
overhead as **-4.6%**, and a negative figure is measurement noise,
not added work. Load-sensitive under a parallel `--workspace` run.
2. **A non-UTF-8 filename fixture cannot be built on macOS.** APFS
enforces valid UTF-8, so `std::fs::write` fails with EILSEQ
("Illegal byte sequence") before the code under test is reached.
`#[cfg(unix)]` is NOT sufficient for such a fixture —
`#[cfg(target_os = "linux")]` is. Cost one red CI round to learn.
### Stage 3b — the Lean language server — MERGED #170 (`main` @ `d400f30`)
- Same worktree `../pmacs-lean-stage3`, **branched off
`lean4-stage3a-seams`, not off `main`** — 3b consumes 3a's response
seam and `pmacs.fs.canonicalize`, so it is strictly sequential.
**Retarget PR #170 to `main` BEFORE merging #167, not after** — the
kill-ring lesson exactly. (Round 1 of this ledger entry stated the
reverse in its first sentence and the correct rule in the next; the
review caught it. A safety rule written twice with opposite senses is
worse than not written.)
- Ships `builtin/runtime/lean.lua` (new), one `include_str!` line in
`src/editor.rs`, `pmacs.lsp._attach_buffer` exported from `lsp.lua`,
a `leanprogress` mode plus `waitForDiagnostics` validation on
`pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (40 tests).
No protocol change.
- **Stage 1's acceptance 12 is half superseded and was rewritten, not
deleted.** It asserted `pmacs.lsp.config.lean4 == nil` to catch a
Stage-3 front-run; 3b is that stage. What survives is the restraint
half — constructing an editor spawns nothing though the config now
names `lake`, and opening a Lean buffer with no server configured
spawns nothing — which is what holds Q#LN7's "not at init" promise.
- **The marker test is wrong in two opposite directions if done naively**
and both are pinned: `io.open` SUCCEEDS on a directory (so truthiness
accepts a `lean-toolchain` dir), but requiring a non-nil read rejects
an EMPTY `lean-toolchain` (a legitimate marker — existence semantics,
not content). Discriminator is `read`'s SECOND return; decline only on
a non-nil err. Probed on LuaJIT 2.1.
- **Fifteen bites recorded, each against the committed tree.** R1: bare
`io.open` → 24a fails / 24b passes; require-non-nil → 24b fails / 24a
passes; no canonicalization → symlinked open spawns two servers; no
re-attach after the swap → three latch tests fail; hook keyed on the
attachment → the missing-`lake` case fails; `waitForDiagnostics`
without `version` → acc37 fails with InvalidParams. R2: skip retiring
a terminal server → `attempt` reaches 3; no originating-buffer gate →
the Lean buffer is left on the `lake` stub; retry-forever → the
failing-fallback test fails; version-probe any command → the
working-wrapper test fails; no disabled guard → the unconfigured test
sees "`nil` could not be started". R3: verdict keyed on `watching`
the late-verdict test finds the buffer still on `lake`; `buf_key`
rewritten per load → the second-buffer test fails; hardcoded
`lake serve` → the wrapper-naming test fails.
- **Round-2 review: three more P1 lifecycle defects, suite 20/20 with
all of them live.** (1) The crashed primary respawned forever —
skipping the retire call avoided corrupting terminal servers but left
`next_restart_at` armed. **`forget` is the call for a TERMINAL server**
(it requires terminal state and removes the client, dropping the
restart timer); `stop` is for a live one and corrupts a terminal one.
(2) Re-attachment targeted whatever buffer was active when the async
verdict landed; an unrelated Rust attachment satisfied "a different
server id". (3) A failing fallback retried every tick forever, silent.
Plus two P2s: the Lake version parser was applied to arbitrary wrapper
output, and an UNCONFIGURED `config.lean4` was reported as failure and
latched, poisoning the session.
- **Round-3 review: two more P1s, both asynchronous correlation, suite
25/25.** (a) `probe.watching` is cleared when the server initializes,
so a SLOW version verdict arrived with nil and retired nothing —
`_attach_buffer` returned the still-live primary and the retry called
it success, so status and config said "fell back" while the buffer
stayed put. **That is the round-1 silent no-op reached through a third
event ordering.** `probe.primary` is now separate from
`probe.watching` and survives initialization. (b) `buf_key` was
rewritten on every Lean `after-load`, so a second Lean buffer opened
before the verdict became the rebuild target while the latch still
watched the first buffer's server. Target buffer and primary server
are one fact and are now armed together, once. Plus a P2: the failure
message hardcoded `lake serve` after the latch became
command-agnostic, sending wrapper users to debug the wrong binary.
- **Round-4 review: one P1, and it is the same defect a FOURTH time.**
`pmacs.lsp.config.lean4` is a single global entry, so swapping its
command invalidates **every** Lean buffer and **every** Lean server —
Q#LN15 gives one per project root. Rounds 13 each fixed the repair
for one buffer and one server; round 4 is "repair the armed target,
strand the rest". The shape that finally holds: retire ALL `lean4`
servers on latch, and repair each buffer **lazily and at most once**
when it becomes active (`buffer.after-switch` + the tick), because
`_attach_buffer` is active-buffer-only and cannot reach the others.
The per-buffer once-only bound is what stops a failing fallback
retrying forever — the round-2 defect a naive global repair loop would
have reintroduced for every buffer instead of one. Plus a P2: the
argument-inclusive attribution was implemented but pinned only by
"contains the command name", so a mutation dropping every argument
still passed.
- **Round-5 review: one P1 plus a frontend scope hole, and four more.**
(1) A fallback that SPAWNS and then dies retried forever: the
once-per-buffer guard bounds `_attach_buffer`, not the server it
produced, and `ensure_server` never forwards `cfg.restart` so the
fallback inherits `OnCrash` — respawned by the manager with no
ceiling, silently, because `latched` had disabled the primary's poll.
The fallback now gets its own one-shot die-before-initialize watch.
(2) **Simultaneous frontends**: both repair triggers read the ambient
`pmacs.window.buffer()`, and the daemon restores `active_frontend` to
the last-dispatched one before `tick_processes`, so a Lean buffer
active in ANOTHER frontend gets no `after-switch` and stays stale.
Fixed at the right seam — **make CONSUMPTION safe**: both
`attached_for_active` and `attachment_for_request` now refuse a record
whose server is dead (the former rebuilds, the latter reports none,
since it must not perturb LSP state). Healing at the point of use is
frontend-agnostic, because whichever frontend runs a command is active
while it runs. (3) The retirement sweep selected on `language_id`, so
it stopped USER-spawned Lean servers too; it now keys on the
`default-lean4` label `ensure_server` stamps, which is the derivation
discriminator. (4) `probe.latched` gated repair even when NO swap
occurred, so an already-fallback config was retried and misreported.
Split out `probe.fallback_installed`. (5) The once-per-buffer
assertion counted TABLE KEYS, which cannot distinguish "once per
buffer" from "every tick for one buffer" — cardinality stays 1 either
way. Now a numeric attempt counter; the bite shows **174 vs 1**.
- **Round-6 review: four P1s and one P2, suite 40/40.** (1) General
point-of-use healing treated a crashed OnCrash server as absent and
spawned beside it while its old id still had `next_restart_at` armed;
`attach_buffer` now forgets a terminal record before replacement.
`attachment_for_request` remains non-attaching and preserves the
record, so a same-id restart can recover instead of being orphaned.
(2) The fallback watch was scalar, while Q#LN15 permits simultaneous
per-root servers and lsp.lua can create them without passing through
Lean's repair function. Watches are now per-SID and discover every
config-driven Lean server from a private origin table. (3) The shipped
`lean.wait-for-diagnostics` command bypassed both safe resolvers and
still consumed a stopped record; it now uses a command-safe resolver,
waits asynchronously for a healed replacement to initialize, and the
test requires the real request to finish. (4) When no config swap
occurred, one failed root still swept a healthy root; that arm now
retires only the SID whose verdict fired. (5) `label` is public and
unreserved, therefore not ownership. lsp.lua records successful
config-driven spawns privately, and every Lean lifecycle decision keys
on that origin fact; the user-server pin deliberately collides on
`default-lean4`. All five bites against `19f48d4` discriminate: the
old files produce 2 same-root servers, a fallback attempt of 4, a
shipped command still targeting `stopped`, retirement of the healthy
root, and retirement of the colliding user server, respectively.
- **DURABLE LESSON — "the test that passes" vs "the test that
discriminates."** Green tests across six rounds repeatedly pinned only
a nearby helper or an absence, and only biting exposed it. **Carry this
to `docs/agent-handoff.md` when the lane lands.** The concrete shapes,
all from this branch:
1. R1 acceptance 36 asserted "every server is terminal" — pinning the
ABSENCE of the fallback it claimed to test.
2. "No live non-fallback server" misses a respawn loop: a respawning
server sits in `crashed` most of the time. `attempt` counts
respawns; liveness does not.
3. Returning to a buffer via `find_or_open` re-fires
`buffer.after-load`, which repairs the attachment regardless of the
code under test. Use `switch_buffer`.
4. A MISSING executable fails synchronously inside `after-load`, where
the rebuild happens inline — no async race can occur. Only the
probe path exercises asynchronous ordering.
5. A mutation that RAISES (indexing a nil config) is swallowed by the
hook's pcall, so the bite "passes" for the wrong reason. A bite must
reproduce the original shape, not merely break the code.
6. A fixture whose `serve` sleeps can never let the primary initialize
first, so it cannot reach the ordering where a late verdict must
retire a LIVE server.
7. Asserting on a field that no longer exists (`_probe.reattach_from`
after a refactor) reads as nil and passes for nothing. Assert
positive facts — a count, a command string — not absences.
8. Counting DISTINCT KEYS cannot bound REPEATED WORK: a per-tick retry
on one buffer keeps `#repaired == 1` forever. Count the attempts,
not the things attempted against (bite: 174 vs 1).
9. A NONEXISTENT executable only exercises synchronous ENOENT. To
reach "spawned, then died", the fixture must actually spawn.
10. Calling the two SAFE HELPERS directly does not pin a shipped
command that bypasses both. Drive the command registry entry and
require its terminal result — replacing a dead record with a
`starting` server is still not success if the request is issued
before initialize.
Rule: **a test is not evidence until the mutation it targets has been
shown to fail it.**
- **SECOND DURABLE LESSON — a scope error repeats until the scope is
named.** The "fallback silently does not happen" defect came back four
times: no re-attach; re-attach cleared by an unrelated buffer;
re-attach satisfied by the server being replaced; re-attach of one
buffer while the others stay stale. Every fix was locally correct and
none asked *what does this config swap invalidate?* — the answer being
every Lean buffer and every Lean server, because the config entry is
global and servers are per-root. **When a change edits shared state,
enumerate everything derived from it before repairing anything.**
- **SUBSTRATE BUG FOUND, not fixed here (framing §6).**
`LspManager::stop` on an ALREADY-terminal server takes its
not-initialized branch, terminates the dead process and sets
`ShuttingDown { .. None }` on the premise that "the next exit
observation cleans up" — but the exit already happened, which is what
made it `Crashed`. No further event arrives, so the client is stuck in
`ShuttingDown` **forever**: `server_is_live` reads it as LIVE, so
`attach_buffer` never rebuilds, and `forget` refuses it for not being
terminal. **Stopping a dead server is what makes it un-replaceable.**
Lean works around it by dispatching on state: `forget` when
terminal, `stop` when live. Merely SKIPPING the call is not
enough — that leaves `next_restart_at` armed.
- Round-1 review found four P1s, all real: the latch swapped the config
but never spawned or re-attached (and acc36 *asserted every server was
terminal*, pinning the absence of the fallback); a missing `lake`
bypassed probe and latch entirely because the hook keyed on an
attachment that ENOENT prevents; `waitForDiagnostics` omitted the
`version` Lean requires; and the ledger stated the dangerous stacking
order.
- The probe's non-zero exit is deliberately NOT a fallback trigger —
§2.9's elan shim makes `lake --version` fail where `lake serve` still
works. Only a parseable version below 3.1.0 triggers it; the
server-failure latch covers the rest.
- Verification on this branch: `cargo fmt --check` clean; strict
workspace Clippy clean; 1,829 default + 2,003 CRDT library tests;
lean4 server 40/40; lean4 stage 1 9/9; dispatch seams 15/15;
multi-root 13/13; M4 121; required GPU 155; **isolated-config
serial workspace sweep 3,229 across 94 suites, zero failures**;
`git diff --check` clean. (Round 1 of
this entry recorded 17/17 and 3,206 — the PRE-fix counts — after the
fixes were pushed. The ledger's protocol is that verification
describes the pushed tree; recording it late is the #161 fmt-blocker
error in a slower form.)
### Stage 4 — framing rev 6, split into 4a/4b (branch `lean4-stage4a-typed-edit-chain`)
### Stage 4 — framing rev 7, split into 4a/4b (branch `lean4-stage4a-typed-edit-chain`)
- Stages 3a and 3b **merged as #167** (`main` @ `6f348c9`) and **#170**
(`main` @ `d400f30`), 2026-07-26. Both were integrated against a main
that had advanced 50 commits mid-review; the only conflict either time
was this ledger's own lane headings, resolved by keeping both sides.
- Worktree `../pmacs-lean-stage4`, branched off `main` @ `d400f30`.
Framing-only so far: `docs/lean4-mode-framing.md` **revision 6**. No
Framing-only so far: `docs/lean4-mode-framing.md` **revision 7**. No
code. Awaiting user approval before implementation, per the workflow.
- **Round 6 review found five P1s, four of them internal to rev 6**
facts about pmacs the revision asserted without checking, while its
external (upstream) facts held. Fixed in rev 7: Stage 4a's footprint
omitted the test file its own acceptance requires; pending
abbreviation state was keyed by buffer when pmacs is **multi-frontend**
(`EditorCore.views` is per-`FrontendId`, `take_typed_edit` is already
frontend-keyed, and `buffer.after-switch` fires with NO arguments, so
a buffer-keyed clear lets any frontend discard another's pending
state); the shortest-match rule was missing its **tie-break by source
declaration order**, which 101 prefixes depend on and a `pairs`-
iterated Lua map cannot express; and the generator's "abort on keys
needing escaping" rule **rejects the real table** (`\` is a key, `"`
begins eleven).
- **A 404 on a guessed path is not evidence of absence.** Rev 6 declared
the upstream package ships no README after fetching the package root,
with the directory listing showing `src/README.md` already in hand.
The README states the tie rule in one sentence.
- **Round 5 re-scout split Stage 4 into 4a (substrate) and 4b (Lean).**
4a is the typed-edit consumer chain — `builtin/runtime/typed_edit.lua`
plus `pair.lua` re-expressed as one registered consumer, no behavior

View File

@ -1,7 +1,8 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-25, after the inline-math slice (#158) landed —
the first mathematical typesetting in pmacs — following find-file (#162),
**Last updated: 2026-07-26, after Lean 4 stages 3a and 3b (#167, #170)
landed — pmacs' first Lean language server — following the inline-math
slice (#158), the first mathematical typesetting in pmacs, and find-file (#162),
the dired arc's Stage 0, and COHERENCE.md (#163), Lean 4 Stage 1 (#160), the
minimap blank-slab fix (#159), bottom-panel Stage 1 (#155), the
inline-math re-scout (#154), the vterm PTY-flake fix (#153), and the
@ -25,15 +26,15 @@ reads it the way you just did.
For volatile branches, checkpoints, verification, and recovery
commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-25)
## 1. Where the project stands (2026-07-26)
- `main` @ `d152120` (the bottom-panel landed-doc refresh #156 atop the
inline-math slice #158, dired Stage 1 #165, the GPU terminal input fix
#166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163,
find-file #162, Lean 4 Stage 1 #160, minimap blank-slab #159,
bottom-panel Stage 1 #155). Protocol unchanged at **v20**. The bullets
below describe the arcs in their own terms; this line is the
head-of-`main` anchor.
- `main` @ `d400f30` (Lean 4 Stage 3b #170 atop Stage 3a #167, the
bottom-panel landed-doc refresh #156, the inline-math slice #158,
dired Stage 1 #165, the GPU terminal input fix #166, Lean 4 Stage 2
#161, the dired framing #164, COHERENCE.md #163, find-file #162, Lean
4 Stage 1 #160, minimap blank-slab #159, bottom-panel Stage 1 #155).
Protocol unchanged at **v20**. The bullets below describe the arcs in
their own terms; this line is the head-of-`main` anchor.
- **`COHERENCE.md` is now required reading and a required framing input
#163.** It carries the product-coherence thesis, an audited
scorecard, per-concern gaps, and §20's priority order, and it is the
@ -42,6 +43,56 @@ commands, read `docs/active-work.md` immediately after this file.
interaction islands added, config-registry adoption, background-work
attribution. Its §2 grades the golden journey **broken at step 3**
(`pmacs .` exits 1).
- **Lean 4 arc (Arc 8) — stages 1, 2, 3a, 3b LANDED**
(`docs/lean4-mode-framing.md`; #160, #161, #167, #170; merge
`d400f30`). pmacs edits Lean 4: `arborium-lean` highlighting, a
`lean4` major mode, `⟨⟩ ⦃⦄ ⟮⟯` pairs, and a `lake serve` language
server with a Lake-aware outermost root, a lazy toolchain probe, a
one-shot `lean --server` fallback, and `waitForDiagnostics`. **No
protocol change in any stage** (still v20).
- **Two of the four stages contained no Lean at all**, and that is the
arc's organizing rule: *no PR mixes a cross-cutting substrate change
with Lean feature content.* Stage 2 made LSP server affinity
per-project-root (`ensure_server` had been reusing one server across
roots — a correctness bug for every language, not just Lean). Stage
3a added notification/response subscription seams to
`handle_server_requests`, the single shared LSP event drain, plus
`pmacs.fs.canonicalize`.
- **Two consecutive re-scouts found that rule broken by the stage
being scouted** — Stage 3 in round 4, Stage 4 in round 5, each time
by a risk column that contradicted its own prose. The rule is not
self-enforcing. Re-check every remaining stage's risk column at
scout time.
- **A configured LSP root must be a canonical absolute path.** It
reaches `file_uri_for` verbatim and that URI is the affinity key, so
one package opened by two spellings spawns two servers. Stage 3a's
`pmacs.fs.canonicalize` is the primitive; it returns nil rather than
a lossy path for non-UTF-8 input.
- **`LspManager::stop` on an already-terminal client strands it in
`ShuttingDown` forever** — `server_is_live` then counts it live so
nothing rebuilds against it, and `forget` refuses it for not being
terminal. *Stopping a dead server is what makes it un-replaceable.*
Stage 3b works around it by dispatching on state (`forget` when
terminal, `stop` when live); merely skipping the call leaves
`next_restart_at` armed. The real fix is unframed substrate work.
- **`elan` shims lie**: `lake --version` and `lean --version` can both
fail ("no default toolchain configured") on a machine where Lean
otherwise works, so `command -v lake` is worthless as a capability
check. Lean acceptance is fake-server; live smokes must be PATH-
**and** success-gated.
- Stage 3b took six review rounds, and **the same defect appeared four
times**: "the fallback silently doesn't happen," as no re-attach,
then re-attach cleared by an unrelated buffer, then satisfied by the
very server being replaced, then repairing one buffer while the rest
stayed stale. Each fix was locally right; none asked what a *global*
config swap invalidates. The durable lesson is to heal at
**consumption** — the point where a stale record is handed out — not
at the moment of the swap.
- Remaining: Stage 4a (typed-edit consumer chain) and 4b (the Unicode
input method) are framed and awaiting approval; stages 5 (goal
panel), 6 (`#eval` output channel), and 7 (module hierarchy) are
framed but not scouted against current `main`.
- **Inline math LANDED — #158** (`docs/inline-math-slice-framing.md` rev 3;
merge `5aa9044`). pmacs renders `$…$` as typeset mathematics in the GPU
frontend. **No protocol change (still v20); the whole slice lives in

View File

@ -46,7 +46,7 @@ during a rebase.
## 0.1 Revision history
Revision 1 — initial.
Revision 1 — initial. Current revision: **7**.
### Round 1 (rev 1 → rev 2)
@ -288,11 +288,15 @@ landed and its citations are historical record, not navigation.
Stages 3a and 3b landed (#167, #170). Re-scouting Stage 4 against `main`
@ `d400f30` produced **six findings that change the plan** and three
that confirm it. The pmacs-side facts were verified in a worktree at
that confirm it. (Round 6 found five more, four of them internal to this
revision; read that section too before trusting a rev-6 statement.) The pmacs-side facts were verified in a worktree at
that commit; the upstream facts were verified by downloading and reading
`leanprover/vscode-lean4` at commit `17d1d08` (2026-05-29) — the
algorithm, not its documentation, since the `lean4-unicode-input`
package ships no README.
package ships no README. *(Round 6: it does, at `src/README.md` — see
that section. Corrections to round 5's own numbers are marked inline
below rather than rewritten, per the standing rule that revision
entries are record, not navigation.)*
1. **Stage 4 violated this document's own splitting rule — the same way
Stage 3 did.** §4 says "no PR in this arc mixes a cross-cutting
@ -377,6 +381,14 @@ package ships no README.
needs a `doNotTrackNewAbbr` guard and why §2.11 records that pmacs
does not.
*Corrected in round 6.* **119** symbols are multi-codepoint, of which
26 carry `$CURSOR`; "93" was the non-`$CURSOR` subset stated as a
total. **Three** values contain a backslash — the `\``\` identity
entry was missed. And this entry's biggest omission is not a number:
the shortest-key rule needs a **tie-break by source declaration
order**, which the README round 5 said did not exist states outright.
§2.11 and Q#LN11 carry the corrected facts.
Confirmations, recorded because each was load-bearing and unverified:
7. **`take_typed_edit`'s one-shot contract is unchanged**
@ -404,6 +416,70 @@ Citation drift repaired per COHERENCE §25, on the same terms as round
and the revision-history entries above, which are historical record
rather than navigation.
### Round 6 (rev 6 → rev 7)
The 4a/4b split held; five P1s against the revision's own content, all
real, all reproduced. Four share a root: **rev 6 verified its external
facts and under-verified its internal ones.**
1. **Stage 4a's declared footprint excluded the tests its acceptance
required.** Q#LN10 listed three production files while 46a46e demand
chain-specific tests that cannot live in
`tests/auto_pair_acceptance.rs` — criterion 46 requires that file
byte-identical. Footprint now names
`tests/typed_edit_chain_acceptance.rs` and adds it to the PR's gates.
2. **Pending state had the wrong owner.** §2.11 reasoned "no
multi-cursor, therefore one point" and Q#LN22 keyed pending
abbreviations by buffer. pmacs is multi-frontend: `EditorCore.views`
is per-`FrontendId` with its own active window, `take_typed_edit` is
*already* frontend-keyed, and `pmacs.frontend.id()` exists. Two
frontends on one Lean buffer — the TUI-plus-GPU case this project
ships — would share one slot. Worse, `buffer.after-switch` takes no
arguments, so a buffer-keyed clear-on-switch lets any frontend
discard another's pending abbreviation. Now keyed
`(frontend, buffer)` with a window check, frontend-scoped
after-switch clearing, a `frontend.detached` purge, and acceptance
45i — which the buffer-keyed design passes every other criterion
without.
3. **The shortest-match rule was missing its tie-break, and rev 6's
research method is why.** Upstream keeps declaration order among
equal-length shortest keys. The README states it in one sentence —
and rev 6 asserted "the package ships no README" after a 404 on the
package root, without checking the directory listing it had already
fetched, which shows `README.md` under `src/`. **A 404 on a guessed
path is not evidence of absence.** The rule is load-bearing: 101
prefixes have equal-shortest candidates resolving to *different*
symbols (`f` → `f<` not `f>`; `"` picks `"A` from eleven). A `pairs`-
iterated Lua map cannot express it, so Q#LN11 now emits an ordered
sequence and Q#LN22 sorts by `(#key, source rank)`.
4. **The generator's rejection rule rejected the current table.** "Abort
on keys needing Lua escaping" would reject `\` and the eleven `"X`
keys — and acceptance 45d requires `\` to work. Replaced with
canonical lossless escaping; the generator aborts only on duplicate
keys, invalid UTF-8, and a failed self-round-trip. Relatedly, 45g
claimed the suite compares against `abbreviations.json`, which is not
shipped; it now pins self-consistency properties and leaves
source fidelity to the generator, where the source is in hand.
5. **Durable and volatile state were not reconciled**
`docs/agent-handoff.md` still anchored `main` at `d152120` with
neither #167 nor #170, while `docs/active-work.md` kept full merged
Stage 3a/3b histories against its own instruction to remove merged
entries, under a stale July 25 snapshot date. Round 5 updated the
ledger and skipped the handoff; per CLAUDE.md both are required
reading, and the one that outranks the other was the one left wrong.
Corrections carried in the same revision, each verified against the
data: the README exists (finding 3); there are **119** multi-codepoint
symbols, of which 26 carry `$CURSOR` — rev 6's "93" was the
non-`$CURSOR` subset reported as a total; **three** values contain a
backslash (`\`, `n`, `setminus`), not two; Q#LN22 now states the rule
acceptance 45d depended on, that an unclaimed terminating `\` is
reprocessed as a new leader; acceptance 38 now says the terminator is
retained, so undo restores `\alpha ` with its space; the coherence
section cites golden-journey **step 5** ("Edit immediately"), not step
4; and §8's config-registry prior art points at Q#LN22, where the gate
now lives.
## 1. What ships
Nine stages, after round 4 split Stage 3 and round 5 split Stage 4. The
@ -828,8 +904,15 @@ them from the store needs a Rust-side policy, not a Lua filter. Q#LN18.
Scouted 2026-07-26 against `leanprover/vscode-lean4` @ `17d1d08`,
package `lean4-unicode-input`, files `AbbreviationProvider.ts`,
`TrackedAbbreviation.ts`, `AbbreviationRewriter.ts`,
`AbbreviationConfig.ts`, and `abbreviations.json`. The package ships no
README, so the algorithm below is read off the source. Apache-2.0.
`AbbreviationConfig.ts`, `abbreviations.json`, and — round 6 — the
package README at `lean4-unicode-input/src/README.md`. Apache-2.0.
**Rev 6 first claimed this package ships no README. It does**, at
`src/README.md` rather than the package root, and the 404 on the root
path was taken as absence without checking the directory listing that
was already in hand. That cost the tie rule below: the README states it
in one sentence, and reading only the code left it as an inference from
`Array.prototype.sort`'s stability rather than a documented contract.
**Resolution.** `findSymbolsByAbbreviationPrefix(p)` collects every key
having `p` as a prefix, sorts them by **key length ascending**, and maps
@ -845,6 +928,32 @@ Verified against the table: `alpha` → `α`, `alp` → `α` (via `alpha`),
surprising enough to be worth an acceptance criterion), `alp7``α7`
via rule 2, `a``α` (`a` is itself a key, among 29 prefix matches).
**The tie rule, and why it is a constraint on the vendored format.**
When several shortest keys have equal length, upstream takes **the one
declared first in `abbreviations.json`**. The README says so outright;
the code achieves it because `Object.keys()` yields JSON insertion order
and `Array.prototype.sort` is stable. Ties are not rare: **101 prefixes
have equal-shortest candidates that resolve to *different* symbols**.
`f` picks `f<``` over `f>```; `"` picks `"A``Ä` from eleven
equal-length candidates; `(` picks `()` over `(=`, `(b`, `((`, `([`.
A Lua table iterated with `pairs` has no order at all, so **a generated
`{ [key] = symbol }` map cannot express this contract** — it would
resolve these 101 prefixes nondeterministically, and worse, *stably
wrong* per build. Q#LN11 therefore carries source rank alongside the
symbol.
**Two things the README explains that the code does not.** `Tab` is the
manual early-replacement trigger upstream binds, which is why
`getReplacementText`'s shortest-prefix rule is user-visible at all
rather than an internal detail. And the `[]_`/`{}_` entries in the table
are not symbols anyone types — they are **decoys**, added so that `\[`
is not uniquely-and-completely matching and therefore does not eagerly
expand before the user can type the second `[`. That is the same
collision Q#LN22 handles from the pairing side, solved upstream by
editing the data. Anyone regenerating the table must not "clean up"
those entries.
**Tracking.** The leader `\` is inserted into the buffer like any other
character, and the tracked range starts after it; the replaced range
spans the leader inclusive (`abbreviationRange.moveKeepEnd(-1)`). So the
@ -881,8 +990,10 @@ abbreviation the cursor has left. pmacs has no cursor-motion hook
(round-5 finding 3), so this seam does not exist here and Q#LN22 makes
abandonment lazy instead.
**The re-arm guard pmacs does not need.** `setminus``\` and `n`
`\n`, so an expansion can insert a backslash; upstream sets
**The re-arm guard pmacs does not need.** Three values contain a
backslash — `\``\`, `n``\n`, and `setminus``\` (rev 6 first
said two, dropping the `\``\` identity entry) — so an expansion can
insert a backslash; upstream sets
`doNotTrackNewAbbr` across the replace so that backslash does not open a
new abbreviation. In pmacs the expansion is a programmatic `buf:replace`
that arms no typed-edit record, so the chain sees nothing and cannot
@ -891,9 +1002,25 @@ contract, not by accident — and the acceptance must pin it, because a
future consumer that inferred from buffer text rather than provenance
would reintroduce the bug.
**What pmacs does not have to carry.** Multi-cursor. Upstream tracks a
`Set<TrackedAbbreviation>` and sorts changes bottom-up for that reason;
pmacs has one point, so one pending abbreviation per buffer.
**What pmacs does not have to carry.** Multi-cursor within a frontend.
Upstream tracks a `Set<TrackedAbbreviation>` and sorts changes bottom-up
for that reason; pmacs has one point per frontend view.
**What pmacs has instead, and rev 6 got wrong.** Rev 6 read "no
multi-cursor" as "one point" and keyed pending state by buffer alone.
**pmacs is multi-frontend**: `EditorCore.views` is a
`HashMap<FrontendId, FrontendView>`, each with its own active window and
cursor; `take_typed_edit` is already keyed by frontend
(`typed_edit_armed: Option<(FrontendId, TypedEditRecord)>`, matched
against `active_frontend`); the record carries `window` as well as
`buffer`; and `pmacs.frontend.id()` is exposed to Lua. Two frontends
editing the same Lean buffer — the ordinary TUI-plus-GPU case, not an
exotic one — would share a single buffer-keyed pending slot, so one
could extend, expand, or silently clear the other's half-typed
abbreviation. `buffer.after-switch` makes it worse: it fires with no
arguments, so a buffer-keyed clear-on-switch would let *any* frontend's
navigation discard a pending abbreviation belonging to another. Q#LN22
keys the state accordingly.
## 3. Decisions
@ -1340,11 +1467,27 @@ claim a reader must be able to check without reconstructing
`src/editor.rs`'s include list.
**Stage 4a ships this and nothing else.** Its whole content is:
`typed_edit.lua`, `pair.lua` re-expressed as one registered consumer,
and the `include_str!` line. Round 5's finding 1 is why this is a PR and
not a first commit — `pair.lua` is every language's auto-pairing, and a
reviewer looking at a Lean PR should not have to also review a rewrite
of it.
| File | Change |
|---|---|
| `builtin/runtime/typed_edit.lua` | new — the chain owner |
| `builtin/runtime/pair.lua` | re-expressed as one registered consumer |
| `src/editor.rs` | one `include_str!` line, before `pair.lua`'s |
| `tests/typed_edit_chain_acceptance.rs` | new — criteria 46a46e |
| `tests/auto_pair_acceptance.rs` | **unchanged, zero lines** |
Rev 6 listed only the first three and then required criteria 46a46e,
which no existing suite can host: the auto-pairing suite must stay
untouched (that is the whole point of criterion 46), so the chain's own
behavior — take-once, priority order, claim-stops-chain, throw
containment — has nowhere to live. A declared footprint that excludes
the tests its own acceptance demands is not a footprint. The new suite
joins the required gate list for this PR alongside
`tests/auto_pair_acceptance.rs`.
Round 5's finding 1 is why this is a PR and not a first commit —
`pair.lua` is every language's auto-pairing, and a reviewer looking at a
Lean PR should not have to also review a rewrite of it.
**The no-behavior-change claim must be pinned, not asserted.** The full
`tests/auto_pair_acceptance.rs` suite is a required gate for 4a and must
@ -1377,10 +1520,24 @@ file rather than estimated:
| 305 keys that are proper prefixes of another | which keys can expand eagerly |
| 1,550 keys uniquely-and-completely matching | the eager-expansion set |
| 26 values containing `$CURSOR` | point placement |
| 93 multi-codepoint values | the replace is not one-char-for-many |
| 119 multi-codepoint symbols (26 of them `$CURSOR`-bearing) | the replace is not one-char-for-many |
| 101 prefixes with disagreeing equal-shortest ties | why the format carries source rank |
(Rev 6 gave the multi-codepoint figure as 93, which was the count
*excluding* the `$CURSOR` entries — a subset reported as a total.)
vscode-lean4 is Apache-2.0.
**Format: an ordered array, not a map.** §2.11's tie rule makes source
order semantic, and a Lua `{ [key] = symbol }` table iterated with
`pairs` cannot carry it. The generated file emits a **sequence**
`{ {key, symbol}, ... }` in `abbreviations.json` order — plus a derived
`key → index` lookup built at load time for the exact-match case.
Resolution sorts candidates by `(#key, index)`, so the 101 ties resolve
the way upstream resolves them and the file's own line order is the
audit trail. A map-shaped emit would be nondeterministic across builds
and, once a hash order happened to be stable, *stably wrong*.
Vendor it as a generated `builtin/runtime/lean_abbrev.lua` with a header
recording source repo, commit, license, entry count, and the
regeneration command — the `builtin/queries/latex/highlights.scm`
@ -1405,12 +1562,29 @@ so the file is self-describing to whoever next touches it. A refresh is
an ordinary PR with a visible diff — which is the point: the diff is the
review.
**The generator must reject a table it cannot faithfully encode.** Keys
are ASCII today but nothing upstream promises that; a key containing a
character the emitted Lua would have to escape, or a duplicate after
normalization, aborts the regeneration rather than silently emitting a
table that disagrees with its source. Same discipline as Q#LN20's
refusal to hand back a lossy path.
**Escaping is canonical and lossless, not a rejection trigger.** Rev 6
said the generator aborts on "a key containing a character the emitted
Lua would have to escape." **That rule rejects the current table**: `\`
is a key, `"` begins eleven keys (`"A` → `Ä` …), and acceptance 45d
requires `\` to work. The generator instead emits every key and symbol
through one canonical Lua string escaper — `\\`, `\"`, `\n`, `\r`,
`\t`, and `\ddd` for any other control byte, everything else literal
UTF-8 — chosen so the emit is byte-deterministic across runs.
What the generator *does* abort on, because these are real corruption
rather than syntax:
- a duplicate key after decoding (JSON permits it; the table must not),
- a key or symbol that is not well-formed UTF-8,
- a round-trip mismatch: the generator re-parses its own output and
compares the full ordered sequence against the source, entry for
entry, and fails if they differ anywhere.
That last check is what makes the artifact trustworthy, and it belongs
in the generator rather than in the acceptance suite — the suite cannot
see `abbreviations.json`, which is not shipped. Same discipline as
Q#LN20's refusal to hand back a lossy path: refuse rather than emit
something plausible.
### Q#LN21 — Stage 4b: the expansion's undo is cross-peer-degraded; ship it, name it
@ -1466,23 +1640,54 @@ that an edit was made.
reconstruction of it:
- `\` typed in a `lean4` buffer opens a pending abbreviation: `{ buffer,
start_offset, text = "" }`, one per buffer, keyed on `rec.buffer`.
window, start_offset, text = "" }`, keyed on **`(frontend, buffer)`** —
see below.
- A subsequent self-insert `c` is claimed iff at least one key has
`text .. c` as a prefix; then `text = text .. c`. If it is also
uniquely-and-completely matching (one of the 1,550), expand now.
- If no key extends `text .. c`, expand `text` **first**, then let `c`
land normally — the chain does *not* claim `c`.
- Expansion resolves through §2.11's three-rule `getReplacementText`,
including the suffix rule (`\alp7` → `α7`).
- **A terminating `c` that is itself `\` is then reprocessed as a new
leader**, opening a fresh pending abbreviation at its position. This
is the rule acceptance 45d depends on (`\alpha\to` → `α→`) and rev 6
specified the acceptance without specifying the rule; upstream gets it
from `processChange`, where a `finished` abbreviation reports
`isAffected = false` and so does not suppress the new-leader branch.
Note this is *not* the `\\` case: there the pending text is empty, `\`
extends rather than terminates, and the result is one literal
backslash with no pending state left open.
- Expansion resolves through §2.11's rules — shortest key wins, ties
broken by source rank, unmatchable tail appended (`\alp7` → `α7`).
- `$CURSOR` is stripped from the symbol and its index becomes the point.
**Ownership is per frontend, not per buffer** (§2.11). The key is
`(pmacs.frontend.id(), rec.buffer)`, and the stored `window` must still
match `rec.window` for the state to be usable — a frontend that moved
the same buffer into a different window is no longer typing where the
pending span is. Two consequences the buffer-only design got wrong:
- `buffer.after-switch` fires with **no arguments**, so it cannot say
whose switch it was. The subscriber reads `pmacs.frontend.id()` at
callback time — documented as "the frontend that produced the most
recent dispatched input event" — and clears **only that frontend's**
entries. A blanket clear would let one frontend's navigation discard
another's half-typed abbreviation.
- `frontend.detached` fires with the raw frontend id and is the purge
seam, exactly as `killring.lua` uses it (Q#KR11). Without it a
detached frontend's pending state leaks for the life of the session.
This costs one table level and buys correctness in the ordinary
TUI-plus-GPU configuration, which is not an exotic setup — it is the
one this project ships two frontends for.
**Abandonment is lazy, because there is no cursor-motion hook** (round-5
finding 3). Pending state is validated at the next typed edit and
discarded when any of these no longer holds: the record's buffer is the
pending buffer; `rec.effective_start` equals `start_offset + 1 +
#text` (the point is still at the end of the pending span); and the
buffer's `revision()` advanced by exactly the pending edit. `buffer.
after-switch` clears it eagerly since that hook *does* exist. The
discarded when any of these no longer holds: the record's buffer and
window are the pending ones; `rec.effective_start` equals `start_offset
+ 1 + #text` (the point is still at the end of the pending span); and
the buffer's `revision()` advanced by exactly the pending edit.
`buffer.after-switch` clears the acting frontend's entries eagerly,
since that hook *does* exist. The
practical difference from upstream: a user who clicks away mid-`\alp`
and types elsewhere gets the pending state dropped rather than expanded.
Upstream expands it. **This is a deliberate divergence** — expanding
@ -2138,6 +2343,11 @@ substrate pin, filed under Stage 4 only because Stage 4 was one stage.
Per the no-renumbering rule above, round 5's additions take letter
suffixes on both sides of the split.
46a46e live in a **new `tests/typed_edit_chain_acceptance.rs`**, which
is part of Stage 4a's declared footprint (Q#LN10) and a required gate
for its PR. They cannot live in `tests/auto_pair_acceptance.rs`, which
criterion 46 requires to stay byte-identical.
46. **Provenance-refactor pin:** the full `tests/auto_pair_acceptance.rs`
suite passes **unmodified**. A suite edited to accommodate the
refactor proves nothing; the diff for 4a must show zero lines
@ -2164,8 +2374,12 @@ suffixes on both sides of the split.
**Stage 4b — the Unicode input method**
38. `\alpha` + space yields `α`; the whole expansion is a single undo
step, and one undo restores `\alpha` rather than `\alph`.
38. `\alpha` + space yields `α ` — the space lands first and the
expansion runs in the following `buffer.after-edit`, so the
terminator is **retained**, not consumed. The expansion is a single
undo step: one undo restores `\alpha ` (with its space), not
`\alph`. Rev 6 wrote the post-undo text as `\alpha`, which would be
true only if the terminator were swallowed.
39. `\<>` yields `⟨⟩` with the point between them, from the `$CURSOR`
placeholder.
40. **Pair-collision pin (Q#LN22).** `\[[]]` yields `⟦⟧`: each `[` is
@ -2211,6 +2425,14 @@ suffixes on both sides of the split.
because the expansion is a programmatic replace that arms no record.
Bites against a future consumer that infers pending state from
buffer text instead of provenance.
45i. **Pending state is per frontend (Q#LN22).** Two frontends attached
to the same `lean4` buffer: A types `\al`, B types `\to` + space in
the same buffer. B's expansion yields `→` and leaves A's `\al`
pending and intact; A then typing `l` + space still yields `∀`.
Plus: B switching buffers does not clear A's pending state, and a
`frontend.detached` for B purges B's entries only. Bites against the
buffer-keyed design rev 6 specified — which passes every
single-frontend criterion above.
45f. **Both producers, and the CI-darkness stated.** The dispatch path
is pinned by the criteria above. The optimistic CRDT producer
(round-5 finding 4) is pinned by a separate criterion driving
@ -2222,11 +2444,30 @@ suffixes on both sides of the split.
verified only locally and name the command. Silence here is the
failure mode — a green CI would otherwise read as covering the path
most users take.
45g. **Table integrity.** The generated `lean_abbrev.lua` round-trips:
its entry count matches the header's declared count, and a spot set
of entries (`alpha`, `to`, `<>`, `+ `, `\`, `n`, `setminus`) matches
`abbreviations.json` byte-for-byte. Bites against a generator that
silently drops or mangles keys (Q#LN11).
45g. **Table integrity — what the suite can actually check.**
`abbreviations.json` is not shipped, so the suite cannot diff
against it and rev 6's "matches byte-for-byte" was unbuildable; a
count plus seven spot entries could not prove 1,855 round-trip
anyway. The full source-fidelity check belongs to the generator
(Q#LN11: re-parse own output, compare the ordered sequence entry for
entry, fail on any difference). What the suite pins instead are
self-consistency properties that a corrupt emit breaks:
- the loaded sequence's length equals the header's declared count,
and equals the declared count for the recorded upstream commit;
- every key is unique, and the derived `key → index` lookup has the
same cardinality as the sequence (a collision would silently drop
entries);
- every key and symbol is well-formed UTF-8, and no symbol contains
`$CURSOR` more than once;
- the resolution spot-set behaves: `alpha`, `to`, `<>`, `+ `, `\`,
`n`, `setminus`, and the tie cases from 45h.
45h. **Tie-break by source order (§2.11).** `\f` + space yields ``
`f<` and `f>` are both length 2, and `f<` is declared first. Same
for `\"` + space → `Ä`, first of eleven equal-length candidates.
**This is the criterion that bites a map-shaped vendored table**:
with `pairs` iteration it passes or fails by hash order, so it must
also be run against a deliberately reversed sequence and shown to
fail. 101 prefixes are exposed to this rule.
**Stage 5 — the goal view**
@ -2294,7 +2535,7 @@ suffixes on both sides of the split.
discipline on transformed source edits, and Q#AP1's optimistic-classifier
limitation. Stage 4a generalizes the first; 4b is built on all three.
- **#127 (config registry)** — `pmacs.config.define` and the
source-buffer-resolution correction. Q#LN10's gate follows
source-buffer-resolution correction. Q#LN22's gate follows
`editing.auto-pair` exactly.
- **#129 (mode system)** — mode-scoped keymaps for Stage 5.
- **#155 (bottom panel)** — `pmacs.window.display` and the panel adopter
@ -2381,9 +2622,10 @@ PR.
(config registry) secondarily, by adding one option in the established
shape rather than a new switch mechanism.
**Golden journey (§2).** No step is touched by 4a. 4b improves step 4
(editing) for Lean specifically and changes nothing for any other
language: the pending-abbreviation state exists only in `lean4` buffers.
**Golden journey (§2).** No step is touched by 4a. 4b improves **step 5
("Edit immediately")** for Lean specifically and changes nothing for any
other language — rev 6 cited step 4, which is "Understand the visible
interface" and is untouched by both stages: the pending-abbreviation state exists only in `lean4` buffers.
Neither stage changes launch, open, or attach.
**Interaction islands (§6).** **None added, and this is the load-bearing