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:
parent
3b54c78494
commit
c4fad0731c
|
|
@ -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#LN1–19), 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 13–21).
|
||||
- **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 1–3 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 46a–46e 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 46a–46e |
|
||||
| `tests/auto_pair_acceptance.rs` | **unchanged, zero lines** |
|
||||
|
||||
Rev 6 listed only the first three and then required criteria 46a–46e,
|
||||
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.
|
||||
|
||||
46a–46e 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue