Stage 3 steps 3 and 4. Omitting `display` now resolves to the PANEL for
listview, compile and terminal; dired keeps `"current"`, passed
explicitly to the shared resolver. Per-adopter `select` per Q#BP12:
listview true, compile false (passive output must not steal document
focus), terminal true.
The census predicted 37 failures across 5 suites and the flip produced
exactly that — same suites, same per-suite counts. The measurement was a
prediction, not an estimate, which is what the inverted step order was
for. Final sweep: 3449 passed / 0 failed against a 3447 baseline, the
+2 being new pins.
THE CENSUS COUNTED FAILURES, NOT CAUSES. Thirteen listview failures had
ONE root cause: a panel is derived-hidden while frame geometry is
unknown, and listview_acceptance never declared any — it never needed to
while listview defaulted to the current window. One helper took it from
13 to 2. The same applied to m4 and vterm_stage2. Geometry is
authoritative state and a grid frontend's real frame size IS its
declaration; the panel suites have always said so.
THREE DEFECTS THE FLIP EXPOSED, each fixed rather than tested around:
1. The OUTLINE panel's `on_visit` used `pmacs.window.switch_buffer` —
the RAW switch, which replaces the buffer in the ACTIVE window. That
was harmless while the outline opened into a document window. Once
the panel became the default the active window WAS the outline panel,
so RET clobbered the panel with the source and left nothing for `M-,`
to return to. The references panel was migrated to `display_file`
when the arc landed; the outline was missed because nothing exercised
it from a panel until now. Q#BP11c names this exact corruption, and
both the outline and compile tests now assert `M-,` FOCUSES the
panel rather than cloning its buffer into the document — an
assertion the previous one could not distinguish.
2. `pmacs.compile._last` stored only `{cmdline, cwd}`, so a recompile
reached `start_run` with no `display` and took the new default. A
user who ran `compile.run{display="current"}` would be moved into a
panel the moment they pressed `g`. An opt-out that reverts on the
next recompile is not an opt-out; `display` is stored and replayed,
with nil kept as nil so an omitted value still resolves to the
default rather than freezing at the first run's resolution.
3. `opts.display` on a nil `opts` — my own regression, introduced by
fix 2 and caught by `journey_acceptance`, which is exactly what that
ratchet is for.
COMPILE'S CHORDS ARE NOW PANEL-LOCAL, and that is a contract rather than
an accidental reachability loss. Every compile chord is bound
`scope = "buffer"`, so with `select = false` none dispatch from the
document — `C-c C-k` included. `acc34` pins it, and pins that
`M-x compile.kill` still reaches the running slot from anywhere via its
`or compile_slot()` fallback. A global chord is a command-surface
decision and belongs in its own framing.
TEST CLASSIFICATION WAS PER TEST, NOT PER SUITE. Two neighbouring
compile tests land on opposite sides: acc15 (RET-visits-error,
jump-back) asserts the NEW default, while acc16 (n/p within compile
output) genuinely needs the buffer selected and says so. compile's
suite-wide helper opts out because ITS subject is compile-BUFFER
behaviour; the placement-subject tests use a second helper that takes
the default. Every opt-out states why. Nothing was mass-added to make a
suite green.
s1_12's two concerns are split as directed: it keeps its Q#GB18
name-keyed-identity bite with explicit `display = "current"`, isolating
the buffer-level `p.prev` skip rule, while a new `s3_1` pins the
side-window presentation chain — C → B → A → delete, ending at the
document with the wrapper collapsed. The mechanisms are complementary:
presentation history chains in the side slot; `p.prev` prevents
raw-switch and capability-fallback loops.
Verified: fmt, diff-check, clippy with and without crdt, --lib 1896,
--lib --features crdt 2081, pmacs-protocol 19, m4 149, required GPU 221,
and the full serialized sweep at 3449/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage 3 step 2 (Q#S3-1). DEFAULT-PRESERVING WITH ONE INTENTIONAL
NORMALIZATION — not "behaviour-preserving", which would be too broad a
claim. Every adopter keeps its current default, and the full serialized
suite is 3447 passed / 0 failed with ZERO suites differing from the
pre-change baseline. But invalid-input behaviour DID move, deliberately,
and that is pinned rather than asserted in prose.
Before this, FOUR adopters validated the same three-value vocabulary in
four places: Rust for the terminal, and hand-written Lua copies in
listview.lua, compile.lua and dired.lua, each carrying its own copy of
the error string. `parse_adopter_placement` read like the shared parser
its doc comment implied but had exactly one caller. Four copies of one
rule is how the next adopter gets it subtly wrong, and the next adopter
is DAP.
`resolve_adopter_display(operation, raw, default)` now owns exactly
three things: the vocabulary, the error text, and the default policy.
Reachable from Lua as the internal seam `pmacs.window._resolve_display`.
THE DEFAULT IS A PARAMETER, NOT A CONSTANT, and that is load-bearing
rather than stylistic. listview/compile/terminal will resolve omission
to the panel in step 3; DIRED MUST NOT, because
`pmacs.path.set_directory_handler` calls it with `{ dest = dest }` and
no `display` key at all — a flipped default would open `pmacs .` in a
bottom panel. Passing the default in makes dired's exemption visible at
its call site instead of hidden in a divergent copy.
TERMINAL'S `window` MUTUAL-EXCLUSION STAYS IN ITS OWN WRAPPER. Only the
terminal accepts a `window` id and only it must reject `window` combined
with `display = "panel"`. A helper pretending the four parsers were
identical would be its own defect.
THE NORMALIZATION, DECIDED AND PINNED. Terminal read
`get::<Option<String>>("display")?`, so a non-string value raised mlua's
TYPE error before reaching any custom message, while the Lua copies
stringified it into their own. Nothing pinned either behaviour — every
existing assertion passes an unknown STRING, which takes the same path
under both designs and therefore could not have caught a regression
here. The custom error wins because it names the legal vocabulary; the
value is rendered by TYPE ALONE (`unknown display (integer)`) so the
message cannot imply a string was passed.
Pinned at the terminal entry point in acc19 — the one adopter whose
behaviour changed — asserting the shared error AND that nothing is
created. The type SPELLING is deliberately not pinned: Lua 5.4 says
`integer` where LuaJIT has no integer subtype, so asserting either
literal would pass on one CI flavor and fail on the other. Verified
46/46 under both.
COMPILE NEEDED AN EXPLICIT OMISSION CAPTURE, and finding that out is
what this step is for. The resolver collapses omission into its default,
but compile's recompile gate distinguishes them: it fires on OMISSION
only, never on an explicit `display = "current"`, which is the
documented opt-out and must reach the raw switch even when the previous
run was panel-placed. Resolving first and testing `== "current"`
afterwards would have silently merged the two and broken the opt-out
with every test still green. `display_omitted` is captured before the
resolver call and the gate keys on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two parts, one intent: record the release, and fix the user-facing
surfaces it falsified.
ABSORPTION. v1.1.0 shipped and nothing recorded it. docs/active-work.md
had no distribution lane, the handoff anchor still named c5f7501, and
COHERENCE.md — a REQUIRED framing input — still asserted "zero release
machinery exists" and graded journey step 1 as source-build-only. Both
are now false, and a framing doc written against them would have been
written against a lie.
* §0 scorecard: step 1 Partial -> Works; §17 Missing -> Partial; the
journey row moves off "steps 1, 11 and 12 remain the thin end" to
name 11 and 12 only.
* §17 ground truth rewritten: what Stage 1 shipped, and the nine
things it explicitly did not.
* §20 Priority 8: "State: zero" -> Stage 1 shipped, with the blocker
it named ("every other priority's value is invisible until this one
exists") recorded as LIFTED. Its next increment is a DECISION about
channels/update/signing, not a queued plan — worth stating so nobody
treats Stage 2 as pre-approved.
* A distribution lane in active-work.md, rewritten-not-removed because
the arc is not done.
Five durable facts move to the handoff §1, each of which cost something
to learn:
* a release build can produce FIVE binaries and three must never ship;
layer-2 exclusion is load-bearing, demonstrated when target/release
still held all three after building only two;
* `env!("CARGO_PKG_VERSION")` expands in the crate being COMPILED, and
three correct tests could not fail while two crates shared a number;
* pin release runners, and assert the glibc floor from the binary
rather than trusting the pin;
* a tag pushed before its workflow reaches the default branch does
nothing, SILENTLY;
* verify from the downloaded artifact, with a negative control — the
1,576 `loro` strings mean nothing without the control build's zero.
STALENESS, found by reading the surfaces a new downloader hits first.
`pmacs --help` claimed the TUI was "currently the only frontend;
reserved for the M4 GUI rollout, where `pmacs` will default to the GUI".
That is not merely dated — it is false in a release that SHIPS the GPU
frontend as a second binary. Rewritten to say what -nw actually does
(name the default explicitly for scripts and wrappers), and --gpu now
states its two real preconditions: a `crdt` build, and pmacs-gpu either
beside the binary or on PATH. Both are things a downloader can get
wrong and neither was documented where they would look.
Also in main.rs: the TLS attach line said "activation in v0.2" and four
doc comments dated themselves "v0.1" or "M4+" while describing behaviour
that is still current. The behaviour claims were accurate; only the
version labels lied, so the labels are gone rather than the sentences
rewritten. One comment gained a correction it needed regardless —
FrontendChoice is IN-PROCESS dispatch and the GPU frontend is not a
value of it, which the old text implied it eventually would be.
README status block: v1.0.0 -> v1.1.0, protocol v20 -> v21, and it
pointed at docs/roadmap-2026-07.md for "current direction" — a file that
opens by calling itself a historical snapshot and redirecting to the
handoff. It now points at COHERENCE.md and the handoff, and mentions the
arcs that landed since it was last touched.
Verified: fmt, diff-check, clippy with and without crdt, --lib 1896,
--lib --features crdt 2081, pmacs-protocol 19, m4 149, required GPU 221,
and `pmacs --help` rendered and read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.github/workflows/ had exactly one workflow and it was test-only: no
release job, no artifact upload, no tags-to-binaries path. Installing
pmacs meant `git clone` plus knowing the feature-flag matrix.
COHERENCE.md §17 grades this "missing — zero release machinery exists";
this moves it to Partial and completes journey step 1.
Scope is one stage: binaries when a `v*` tag is pushed, attached to a
GitHub Release. Channels, rollback, update-in-place, signing, RHEL 9 and
Intel macOS are out of scope and named in the framing's §5.
WHAT SHIPS: pmacs and pmacs-gpu, both at 1.1.0, CRDT-enabled, co-located
in one archive, with SHA256SUMS. pmacs-protocol stays at 1.0.0 — it is
the wire crate and versions on its own schedule.
THE VERSION BUMP EXPOSED A REAL DEFECT, and it is the reason this PR
touches src/ at all. `InstanceIdentity::for_running_process` is defined
in pmacs-protocol and expanded `env!("CARGO_PKG_VERSION")` THERE. `env!`
expands in the crate being compiled, so the field documented as "Pmacs
version string" carried the PROTOCOL crate's version. That identity
reaches Lua as `pmacs.instance.identity()` and goes on the wire in
`Hello`, so a 1.1.0 release would have told every attached frontend it
was 1.0.0.
Nothing could have caught it earlier. Three tests assert
`id.pmacs_version == env!("CARGO_PKG_VERSION")` evaluated in the pmacs
crate — the correct assertion — but while both crates read 1.0.0 they
compared the same number reached by two different paths and COULD NOT
FAIL. Deciding to hold pmacs-protocol at 1.0.0 while moving pmacs is
what made them discriminating; all three failed on the bump. The version
is now a parameter so `env!` expands in the caller's crate. A test can
be correct and still prove nothing when the two things it compares are
equal for a reason unrelated to the code under test.
TWO LAYERS OF BINARY EXCLUSION, and layer 2 is load-bearing —
demonstrated, not argued. Cargo auto-discovers src/bin/*.rs, so a
release build can produce five binaries and three must never ship
(pmacs-audit is a contributor tool; pmacs_fake_lsp and pmacs_fake_mcp
are test fixtures). Layer 1 names explicit --bin targets. Layer 2 stages
an explicit asset list, and building this branch produced exactly the
case it guards: after building ONLY --bin pmacs and -p pmacs-gpu,
target/release still held all three forbidden binaries, left by an
earlier `cargo test --release`. Swatinem/rust-cache restores that kind
of directory in CI. An implementation trusting layer 1 and archiving the
directory would have published a fake language server in the first
release.
The three archive assertions are bite-verified: a smuggled
pmacs_fake_lsp, a missing pmacs-gpu, and a cleared executable bit are
each caught, with the honest archive passing.
THE GLIBC FLOOR IS ASSERTED, NOT TRUSTED. Pinning ubuntu-22.04 sets the
floor at 2.35 (Ubuntu 22.04, Debian 12 — NOT RHEL 9 at 2.34, which needs
a container or cross-build and is parked). But a pinned runner proves
nothing about the artifact, and the failure surfaces as a bare
`GLIBC_2.39 not found` on a user's machine with no clue which commit
caused it. The build reads versioned-symbol requirements out of the
binary and fails above the floor, so switching to ubuntu-latest fails in
CI instead of shipping. Bite-verified both directions on a glibc 2.44
host. Both runners are pinned; macos-latest would drift the minimum
supported macOS with no commit to point at.
Preflight runs before any build: the tag must match the root crate
version (stripping a prerelease suffix, so v1.1.0-rc.1 and v1.1.0 both
match 1.1.0), and the tagged commit must be an ancestor of main. Both
catch mistakes that are cheap now and expensive once a public URL
exists. The suite is not re-run — CI already tested the commit — but
nothing otherwise enforced that a tag points at a tested one.
Verified: fmt, diff-check, clippy with and without crdt, --lib 1896,
--lib --features crdt 2081, pmacs-protocol 19, m4 149, required GPU 221,
and the full serialized crdt sweep at 3,715 passed / 0 failed / 30
ignored — identical to the pre-change baseline, so the protocol
signature change broke nothing. Archive staging, contents, executable
bits and both --version outputs were exercised against a real release
build locally.
No release is cut by this PR. Per the framing's §7 the RC is tagged
after merge, from the merge SHA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo clippy --workspace --all-targets --features crdt -- -D warnings`
has never passed on main. The standing gate list runs clippy without
`crdt`, so these lints have never been enforced, and any CI job that
compiles the crdt targets would be red on arrival. This is framing §7
step 1: nothing else in the lane is testable until it lands.
Eight findings across four files, none behavioral:
src/daemon.rs useless_conversion (u64)
src/daemon.rs missing doc backticks
src/daemon.rs too_many_lines (112/100)
tests/auto_indent_crdt_acceptance.rs missing doc backticks
tests/bottom_panel_stage2b_gpu_acceptance too_many_lines (104/100)
tests/vterm_stage3_acceptance.rs too_many_lines (122/100)
tests/vterm_stage3_acceptance.rs too_many_lines (132/100)
tests/vterm_stage3_acceptance.rs redundant `continue`
--keep-going is what made this an inventory rather than a lower bound.
docs/active-work.md recorded seven findings at 74301d1 and correctly
warned they were "a lower bound, not an inventory" because clippy
abandons remaining targets once one fails. With --keep-going the set is
complete, and it differs from the ledger's in both directions: the
`unneeded mut` at src/daemon.rs:4965 is gone (fixed incidentally by
later work), a finding in bottom_panel_stage2b_gpu_acceptance.rs is new,
and every src/daemon.rs line number had moved. A stale lint inventory is
worse than none — it invites fixing lines that no longer exist.
The four too_many_lines findings are silenced with a reason rather than
refactored. Refactoring a test body to satisfy a lint that has never run
would be a behavioral change riding a CI-configuration lane, and the
codebase already has ~20 `#[allow(clippy::too_many_lines)]` sites, the
best of them carrying `reason =`. Each reason states why the scenario is
one test: the GPU acceptances exist specifically to prove a real
daemon, a real PTY and real wgpu fit together, which splitting would
hide.
The redundant `continue` needed care. Replacing it with `Err(_) => {}`
traded the lint for `single_match` — the match then destructured one
pattern. Rewritten as an edition-2024 let-chain, which drops both
without changing semantics: an unreadable message still falls through
to the next loop iteration.
Verified: clippy green with and without `crdt` (the second confirming
no regression to the enforced gate), fmt, diff-check, --lib --features
crdt 2081 passed, and the three touched suites green — vterm_stage3 at
9/9 in 4.34s rather than 0.17s, so a37 really ran rather than reporting
ok on a missing binary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements `docs/discovery-stage1-command-family-framing.md` (approved
at revision 6). `COHERENCE.md` §5 graded discoverability "substrate
without surface": the registries already carried descriptions, source
locations and reverse key lookup, and almost none of it was reachable.
Eleven commands under one `help.*` prefix, so typing `help` at M-x
surfaces the whole family. Nine are new; `editor.describe-command` and
`editor.describe-setting` are renamed in, with the old names retained
as forwarders so nothing documented breaks.
No Rust. Every command renders data `pmacs.describe.*`,
`pmacs.keymap.list()`, `pmacs.command.list()` and `pmacs.config.list()`
already return, and `describe-setting`'s completion source is a Lua
function via `CompletionSource::Custom`, which needed no binding work
either — correcting a comment in `default.lua` that claimed `source`
was a fixed Rust-side vocabulary.
`apropos` matches by substring, not fuzzy: `fuzzy_score` is
subsequence-based and descriptions are long sentences, so fuzzy would
match nearly every command.
Two disciplines the file keeps. Every command renders through the
public `pmacs.editor._show_help`, which buys one owner for the shared
`*help*` policy — reuse-by-name, wholesale replacement, `q`, and the
foreign-buffer hazard. It does NOT buy a one-site migration to
`src/help.rs`, which has no renderer for settings, lists or apropos; so
rendering is a named per-subject function, and the future Rust work is
enumerated per subject rather than discovered per call site.
The seam-counting pin earned its place immediately: the two renamed
commands were still calling the file-local `show_help_text`, so the
funnel was fiction for exactly the two commands that predate it. They
now call the public seam, with a comment saying why the local is not
used from the same file.
Moves `help` out of `welcome.lua` into the new `runtime/help.lua`,
which owns the family and loads after it so the index can read
`pmacs.welcome.entries`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
`EditorState::new` resolves two storage roots from the process
environment before it returns: the data root, which the bundled-package
materialization then WRITES into unconditionally (outside every `cfg`
guard), and the config root, from which `init.lua` is read.
The `#[cfg(not(test))]` guard on the second was written to stop the
crate's own unit tests picking up a developer's real `init.lua`. It does
exactly that and nothing more: `cfg(test)` is set only while compiling
the lib's own tests, so an integration test in `tests/` — compiled
without it — reads the real config and writes the real data root. On a
machine with a real `~/.config/pmacs/init.lua`, that is 11 deterministic
failures in `compile_mode_acceptance`, attributed to whatever branch is
checked out.
Tests cannot fix that themselves: `std::env::set_var` is `unsafe` and
this crate is `#![forbid(unsafe_code)]` — the same constraint that
produced `Installer::with_install_root_override`. So isolation arrives as
a parameter.
`BootstrapRoots` names the four storage roots (config, data, state,
cache). `ambient()` leaves every one `None` and every resolution goes to
the environment exactly as today, so production is unchanged.
`new_with_roots` and `open_with_roots` take it — both, because `open`
calls `Self::new()` internally and a constructor-only parameter would
leave every open-path test ambient. `install_state_dirs` consults it
too: it runs after construction, so resolving from the environment there
would reopen the hole the constructor closed.
The redirected branch changes WHICH directory is read, never WHETHER the
block runs. Config loading shares one conditional with
`set_init_complete()`, and `tests/m8_2_acceptance.rs:75` documents its
dependence on integration-test construction finishing init-complete.
`child_env()` translates the same value into the environment a spawned
`pmacs` needs. Five variables, not four: `PMACS_STATE_HOME` outranks
`XDG_STATE_HOME`, so a child given only the XDG four still resolves an
inherited state override — invisible on a machine that exports none.
The `src/editor.rs` comment claimed a protection it does not provide and
said nothing about the write above it; both are corrected in place. The
guard is deliberately NOT widened to cover integration tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Review round 1 on #205, two findings, both accepted.
The greeting was written straight into the registry without calling
`notify_buffer_edit`. The window's `TextView` had been indexed while
`*scratch*` was empty, and newlines are zero-width to a painter working
from a stale line index — so the first TUI frame collapsed the whole
three-line greeting onto row 0. Every buffer-text assertion passed
because the buffer content was correct; only the rendering was wrong.
The edit is now captured, the registry borrow released, and the core
notified.
The pin that would have caught it paints a real frame and asserts the
second line occupies its own row AND that row 0 does not contain it —
both directions, because a one-direction check passes when everything
collapses upward. Bitten by dropping the notify call: row 1 comes back
empty with row 0 holding the lot, and it is the only pin that fails.
Second: the project docs still described the arc as it was two PRs ago.
`COHERENCE.md` §20 called 1b-2 in flight and the welcome buffer
unstarted; its arc list said 1b-3 remained; and the ledger's journey
lane header still read "1b-2 PR OPEN" while the 1b-3 block carried a
mangled "Framing only; no code" line left by an earlier edit. All now
describe the PR-head state per §25.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Implements `docs/journey-stage1b3-welcome-framing.md` (approved at
revision 4, after three review rounds). The last of the 1b split.
`COHERENCE.md` §18 graded onboarding "missing entirely": no welcome, no
cheat sheet reachable from inside the editor, and `M-x` — the only door
in — discoverable only by already knowing about it. A fresh `pmacs` now
greets an untouched `*scratch*` with three lines naming `M-x` and four
real bindings, and `M-x help` renders a cheat sheet.
The startup seam is the substance. No constructor is the right hook:
`EditorState::open` calls `new` before resolving its target, the daemon
constructs one too, `init.lua` runs inside `new`, and desktop restore
happens later still. So `run()`'s terminal-free prefix is extracted into
`prepare_startup`, which `run` delegates to, and the greeting happens
there — after config, after attach dispatch resolves to local, and
after desktop restore. Extracting it is also what makes the wiring
testable: with the greeting called by hand from tests instead, deleting
the production call would leave every assertion green while shipping no
welcome.
Lua owns what is said, Rust owns when and where. `pmacs.welcome.entries`
is a structured list that both renders the text and drives the binding
checks — scraping the rendered prose would be ambiguous, since `C-c c`
is two chords and nothing in the text marks the boundary.
The greeting is deliberately NOT written through
`set_generated_contents`: that would lift read-only, discard history and
mark the buffer generated, all wrong for the buffer journey step 5
requires the user to type into immediately. It is left unmodified so it
does not look like unsaved work.
`M-x help` renders through `editor.describe-command`'s existing `*help*`
mechanism via a new `pmacs.editor._show_help` seam, rather than growing
a second help surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Review round 1, one finding, accepted.
"A failed escalation is never retried by anything" was false.
`shutdown()`'s force-kill loop iterates the reap ledger with **no**
`!entry.killed` guard, so it does re-kill an entry the escalation arm
gave up on. The accurate claim is that no later *tick* retries it —
`tick_reap_ledger`'s escalation is guarded by `!entry.killed` and never
fires again for that group.
The overclaim collapsed two failure modes that this lane exists to keep
distinct: a failed escalation leaks the group until editor exit, where
one more attempt is made, while a failed `shutdown()` force-kill leaks
it past exit with nothing left to try. Narrowed in the framing, the
handoff, the active-work ledger and the test commentary.
The corrected claim was asserted in three documents and pinned by
nothing, so it gets a pin: a failed escalation marks the entry, the
survivor stays alive across ticks, and `shutdown()` — with no fault
planned, so its force-kill really lands — still reaps it. Bitten by
adding the missing `!entry.killed` guard to that loop: the new pin
fails and the other five stay green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Found by bite-testing, not by review. With the in-drain seam reverted,
the pin still passed both content assertions and failed only the
consumed-plan check — meaning "LATE-MARKER is absent" was holding for
a reason unrelated to the probe.
`poll_one` sends SIGTERM to the whole group on leader exit, so the
untrapped descendant died before its 0.5s sleep finished. The late
marker never arrived on *either* path, which makes the absence
assertion vacuous: it would have stayed green with the collapse fixed.
The descendant now installs `trap '' TERM` behind `survivor_script`'s
readiness gate, so it survives the group TERM and writes its marker at
0.5s — well inside the 2s drain timeout the real path would run to.
Re-bitten: the reverted seam now fails on the LATE-MARKER assertion
itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Implements `docs/reap-ledger-silent-failures-framing.md` (revision 3).
Diagnosis only: every pin asserts CURRENT behaviour, including the
behaviour that is wrong. No disposition changes.
The ledger discards the result of four `kill(2)` calls, and each
discard has its own consequence:
- the liveness probe cannot tell ESRCH from any other errno, so an
unreachable group is deleted from the ledger exactly as a dead one
is, cancelling its escalation;
- the deadline escalation sets `killed = true` whether or not the
SIGKILL landed, so a failed one is never retried by anything;
- `shutdown()`'s force-kill does the same on the path written
specifically to stop a leak at editor exit;
- `final_drain_runtime`'s twin collapses every errno into "dead",
which quiesces the drain and cancels the readers.
None had an injection seam, so none was testable. This adds one, on
Q#PD4's terms: the injection replaces the *result only*, and every
branch, syscall and bookkeeping step downstream is production code.
The seam is directed per site, because `shutdown()` signals every
managed process before it reaches its ledger force-kill — a single
undirected slot would be eaten by the wrong call and the test would
pass while proving nothing. The persistent sites take a FIFO each,
since the shutdown-coupling pin needs a failed force-kill and a failed
subsequent probe pending at once. The in-drain site instead takes one
outcome that repeats for one whole drain: a one-shot is consumed by
the next 1ms probe and can never survive the 50ms window `quiesced`
requires.
Fixture state is per-supervisor, never global, and teardown asserts
every planned outcome was consumed — an unconsumed plan means the
fixture never reached the site, which would leave an absence
assertion vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Review round 5: one blocking, one major. Both documentation-only; the
runtime fixes from round 4 are unchanged.
**The pre-kill sample was overclaimed.** Acceptance 4 said the measured
group "describes the target that was attempted". It does not. `getpgid`
and `kill` remain separated by the same read-then-act window §1.5 is
built on, so the sample can be stale by the time the signal is
delivered. Moving it earlier removes a POST-HOC reading; it does not
make the reading contemporaneous. The criterion now says it records
pre-kill evidence about the attempted target, and adds an explicit
sentence forbidding any acceptance from claiming otherwise.
This is worth naming rather than quietly editing: the framing's whole
spine is that this telemetry establishes less than it appears to, and
round 4 fixed a real ordering defect and then inflated the fix's meaning
inside the document that governs it.
**`95897f7`'s commit message carries the same overclaim.** It is not
amended — force-pushing a branch under review to rewrite history would
hide the error rather than correct it. This message is the correction,
and the two read in sequence.
**A dead doc comment was documenting a constant.** The original
pre-Stage-B acceptance-1 block survived the rewrite and came to rest
above `const BASH`, so the file explained a string constant with the
sentence this entire lane exists to remove — "here they are asserted to
agree only because nothing has moved the terminal", describing a test
that no longer exists. Removed; only the BASH explanation remains.
Verified the overclaim is gone from the whole tree, not only the cited
line: no hits in `docs/` or `src/`.
Gates: 11 gates, 4,471 tests, zero failures.
Review round 4: three blocking, one major. All accepted.
**`measured_group` was sampled after the failure.** It ran inside
`signal_failure_report`, after the `kill` and after `observe_leader`,
while the framing and the function's own doc both said before. A
concurrent group change would have made the diagnostic report
post-failure state as evidence about the attempted target. It is now
sampled in `signal` before the kill and passed into the report, so the
field describes the target that was attempted rather than the state the
failure left behind.
**The Linux corroboration did not exercise the production lookup.** Its
helper read `portable_pty::process_group_leader` — the accessor this
lane stopped using on the signal path — so `pty_foreground_group` could
have fallen back on every call with every test still green. Forcing it
to always fall back demonstrates the hole exactly: the corroboration
fails, and the injected pin PASSES, because the injected tests supply
the group themselves and structurally cannot detect a broken lookup.
The helper now calls the production lookup, and the corroboration forces
only the kill so the report is built from a real terminal read. The
residual limitation is recorded rather than left to the green: on macOS
`pty_foreground_group` has no end-to-end coverage, because the platform
cannot produce the precondition.
**The framing did not update its own acceptance contract.** Revision 5
recorded Bet 1's falsification in the revision history and in the bet,
and left the normative criterion demanding the real-shell rewrite — the
"implementation quietly diverges from the contract" shape this project
already recorded as a lesson on #191/#188. Acceptance 1 now describes
the injected pin, 1a adds the corroboration and its macOS limitation,
and 4 states the sampling order. The ledger is synchronized: revision 6,
four commits, 4,471 tests, bash armed on Linux only.
**`TargetSource`'s doc had the wrong classification.** Two of the four
variants target the leader pid, not one, and the pid-versus-group split
does not line up with PTY-versus-pipe — which is why the fallback needed
its own variant rather than reusing `LeaderPid`.
Gates: 11 gates, 4,471 tests, zero failures.
CI falsified framing Bet 1. Both macOS legs reported
job control never moved the terminal off the leader
(leader=8542, foreground groups observed: [8542])
with the terminal staying on the leader for the entire 10s bounded wait.
Linux diverges reliably — 20/20 locally and green on both ubuntu legs —
so this is a platform difference rather than a flake, and rerunning past
it would have been wrong.
The framing named this outcome and prescribed the response, so that is
what ships rather than an improvised fix:
- The divergent case is pinned by INJECTING the foreground group at the
`signal_target` seam. Deterministic, runs on every platform. The
injection seam widens from failure-only to either outcome; the branch,
target choice, leader observation against the real child, and report
construction all remain production code.
- Verified still discriminating: the `leader_pid`-substitution mutation
fails it, `target=-1707909` against an expected `-1707910`. That was
the whole point of the original rewrite and it survives the fallback.
- The real shell is retained as corroboration in
`job_control_really_diverges_the_foreground_group`, Linux-only. It
skips on macOS by PLATFORM CHECK rather than by arming: the
precondition genuinely does not hold there, so running it would assert
a false claim about macOS instead of finding a bug.
- Framing revision 5 records the falsification and states exactly how
the injected pin is weaker — it proves the target is read from the
lookup rather than substituted from the leader; it does not by itself
prove any real shell produces that divergence.
`PMACS_REQUIRE_BASH` moves to Linux-only. The earlier reasoning for
arming both platforms — macOS is where the failures happen, so Linux-only
leaves it dark where it matters — was right about the diagnostic and
wrong about this test, which cannot produce its precondition on macOS at
all. Arming it there made a missing binary fatal for a test that can
never run. The measurement is recorded in ci.yml and the README so it is
not re-derived.
Gates on this tree: 11 gates, 4471 tests, zero failures.
Framing acceptances 2, 3, 4, 5, 7 and 8. Evidence collection only: no
tolerance rule, no change to which process is signalled, no disposition
change.
Three distinct failures previously rendered as one string.
**The PTY fallback is now named.** When a PTY's foreground-group lookup
yields no group, the target falls back to the leader — and until now that
rendered "leader-pid", identical to a pipe child that never had a
terminal. `portable-pty::MasterPty::process_group_leader` collapses every
failure into `None` before pmacs can see it, so the errno was gone too.
pmacs now performs the query itself and reports four distinct outcomes:
no master fd, a failed duplicate with its errno, a failed `tcgetpgrp`
with its errno, and a non-positive answer.
Doing that without `unsafe` is the interesting part. `nix::unistd::
tcgetpgrp` needs `AsFd`; `MasterPty` exposes only `Option<RawFd>`; and
every std route between them is `unsafe`, which this crate forbids.
`filedescriptor::OwnedHandle::dup` takes any `AsRawFd` through a safe
blanket impl and returns an owned handle that IS `AsFd`, so a
lifetime-tied view implementing one safe trait is the whole bridge. The
borrow is what makes it sound: the view cannot outlive the master, so the
descriptor cannot close underneath it.
**The report names the signal.** A failed SIGUSR1 and a failed SIGTERM
were the same text. Note this is a reporting gap only — every failed
`kill` returns before the fatal-signal branch, so failed signals are
disposition-identical whatever they are. A separate control pins that the
fatal/non-fatal difference is real for calls that SUCCEED, which is what
gives the first test its meaning.
**`measured_group` is a real observation.** `expected_group` is
`-leader_pid`, and on the spawn-group path the target is `-leader_pid`
too, so the report printed the same number three times and their
agreement was arithmetic rather than evidence. `getpgid` supplies the one
field that can disagree. It establishes no identity — it is read inside
the same read-then-act window, and no portable mechanism closes that for
a group.
Bites, each by an actual revert, all observed to fail:
- collapsing the PTY fallback back into a bare "leader-pid";
- dropping `signal=` from the report;
- making the measured group restate the pid it was handed;
- replacing the job-control fixture with a plain `sleep`, as a positive
control on the divergence fixture itself.
All four exact-string sites were updated individually, never by a blanket
rewrite: a wholesale rewrite of expected strings is how a format
regression hides. `:2501`'s first-call disposition pin is retained and
updated for the new format rather than replaced.
`nix`'s `process` feature is now declared explicitly. It already arrived
transitively — nix's own `signal` feature depends on it — which is stable
but invisible, and a real requirement resting on another feature's
internals is one refactor away from vanishing. `filedescriptor` is
declared directly for the same reason: pmacs now calls its API.
The reap ledger's comment claiming "EPERM cannot happen for our own
children" is corrected. Its bounded-growth policy is unchanged, but the
justification was wrong: the probe targets a group, and owning the
spawned child says nothing about a group unless the child is still a
member — which nothing measures. The handoff records this together with
the limit of the evidence: the occurrence does NOT establish that the
child itself received EPERM.
Framing §3 Bet 1 and acceptance 1. Committed alone, per the branch plan:
this bet decides whether the diagnostic is worth extending at all, so its
result belongs in history before anything depends on it.
The previous version of
`a_group_directed_kill_failure_reports_target_and_leader_separately`
spawned `/bin/sleep` on a PTY and asserted the same pid three times,
conceding in its own doc comment that the values "are asserted to agree
only because nothing has moved the terminal". The entire premise of the
diagnostic is that the terminal's foreground group and the spawned leader
are different entities, and no test exercised a case where they were.
The fixture now drives job control: `bash -m` runs a foreground job in a
fresh process group and hands it the terminal, so `tcgetpgrp` reports a
group that is not the leader. The trailing `; :` is load-bearing — with a
single simple command `bash -c` execs in place, which would leave the
leader owning the terminal and silently restore the agreement.
The bounded wait is also load-bearing rather than defensive. A probe of
this fixture observed the foreground group as the leader FIRST and only
then as the job's group, so measuring immediately would pin the
non-divergent case. The fixture additionally asserts the diverged group
still holds a live member, so a divergent number cannot come from a dead
group.
Falsified in both directions by substituting `leader_pid` for the
`tcgetpgrp` result in `signal_target`:
- the new test FAILS — target=-1020100 (leader) against the expected
-1020103 (foreground group);
- the OLD test, restored verbatim alongside the same mutation, PASSES.
That pair is the finding: the previous acceptance pinned the
substitution as acceptable.
`/bin/bash` is declared as an optional test dependency and armed with
PMACS_REQUIRE_BASH on BOTH CI platforms, not only Linux — the signal
failures this diagnostic exists to explain have so far occurred only on
macOS. The guard tests the exact path the fixture spawns rather than
`which bash`, because a system with bash on PATH but not at `/bin/bash`
would pass a `which` guard and then fail the spawn.
Verified: both arming arms exercised against an absent path (unarmed
skips, armed fails by name); 20/20 repetitions of the test; `cargo fmt
--check`, `cargo clippy --workspace --all-targets -- -D warnings`, and
`cargo test --lib` (1877 passed) all clean; ci.yml parses.
No production behaviour changes.
`docs/active-work.md` was the only conflicting file, in the same shape
as #191's: `main` inserted the generated-buffer Stage 1 lane immediately
above the bottom-panel header this branch had rewritten. The resolution
keeps both.
Each side's newer text wins where that side owns the fact: `main` carries
the corrected #188 status (MERGED/APPROVED, replacing "OPEN, PROPOSED —
do not implement"), and this branch carries the bottom-panel lane's 2B-3
state and the newer snapshot date, replacing main's "2B-2 MERGED; 2B-3 IS
NEXT" and its 2B-2 boundary paragraphs.
Verified: no conflict markers; every line absent from either parent is a
deliberate supersession by the other, enumerated and checked one by one
rather than counted; all three lane headers present exactly once.
`docs/active-work.md` was the only conflicting file. #196 added the dired
Stage 2a lane at the position this branch had used to relabel the #188
framing lane header; the resolution keeps both, changing neither side's
wording.
`src/editor_core.rs` auto-merged. Both lanes touch it, so a clean
textual merge is not evidence of a clean semantic one — the gate suite
is re-run in full on the merged tree rather than inherited from the
pre-merge head.
Resolution verified for line loss in both directions: the resolved file
differs from `main` only by this branch's own authored edits, and
differs from this branch only by additions taken from `main`.
Review round 2, three findings.
setsid is util-linux, not coreutils, and the standard `cargo test --lib`
gate must not hard-fail on a tool the README does not declare -- a
minimal or BusyBox container would fail without ever testing pmacs. The
hard assert becomes skip-unless-armed via PMACS_REQUIRE_SETSID, which is
the pattern the silent-skip lane already established, so the test cannot
quietly report `ok` having never run where the tool is guaranteed. CI
arms it on Linux; README declares it. Both arms verified against a PATH
with setsid genuinely removed: unarmed skips with its message, armed
FAILS with the diagnostic.
The durable causal account was wrong, and this corrects it in the
framing, the handoff and the ledger. basedpyright's console script runs
bundled node through `subprocess.run` and WAITS
(nodejs_wheel/executable.py:50, verified in the installed 1.39.6). It
does not exit at spawn. What orphans node is pmacs: `shutdown()` SIGTERMs
the recorded pid -- the Python wrapper -- which dies without forwarding
the signal, leaving node at PPid 1 holding the pipes. The refutation was
already in hand: the initialize handshake succeeds, which a wrapper that
exited at spawn could not have done, and the PPid 1 observation was taken
after shutdown had killed it.
The fix is unaffected -- the deadlock and its bite are unchanged -- but
the parked follow-up changes target: not "tolerate servers that
self-orphan" but "stop orphaning them", i.e. signal the process group
rather than a wrapper pid that swallows the signal. Framing section 5 P2
restated.
Also corrects a stale CI-ordering claim: the handoff said pyright must
stay unarmed until the timeout lane lands, but #195 is this PR's base and
gave every job a timeout-minutes. The one live reason is that CI does not
install basedpyright at all. The ci.yml comment asserting the job has no
timeout-minutes was stale for the same reason and is rewritten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Review round 1: six findings, four sharing one shape — the panel layer was a
partial port of the document/terminal layer, and the tests asserted the
declaration side only, so each omission was invisible. Audited as a port
rather than patched as a list.
GEOMETRY AGREEMENT (P1). Three grids had drifted apart. The declaration
subtracted `TEXT_LEFT` from its width against the parent framing's explicit
contract ("`total.cols` describes the full-width panel grid beginning at
x=0; document `TEXT_LEFT`/gutter padding is unrelated"), while painting and
hit-testing used the document-dependent `mono_advance` and the declaration
used the stable probe. So daemon columns could overflow the surface and a
click could resolve to a different cell than the one painted — and the new
test separated the two advances and then asserted only the declaration, so
it saw none of it.
The fix is structural, not three edits: the advance is cached BEHIND the
declaration (`PanelBand::declared_advance`) and painting and hit-testing read
it. They cannot disagree, because there is one value. The band's rect is now
x = 0 across the full surface width, and the fractional right-edge remainder
is band background that maps to no cell — which is what the framing says and
what `hit_test_cell`'s column bound already enforced.
GESTURES (P1). Only `Move` was sent. Left press never armed, so `Drag(Left)`
was never emitted and panel selection could not work; releases outside the
band were dropped, leaving the daemon holding a button down; right-click and
wheel never consulted the band at all and were applied to the document
underneath.
The root cause is that four handlers each decided for themselves whether the
band owned a pixel, and three did not ask. There is now ONE authority —
`PointerSurface` / `classify_pointer_surface` — and all four route through
it, so a future handler cannot quietly forget the band. `PanelBackground` is
its own arm: the remainder is the band's pixel even though it emits no
`PanelPointer`, so it must not fall through either.
PASSIVE CARET (P1). The producer ships `cursor` for a passive panel too — it
is the window's real point and the daemon does not suppress it — so painting
it unconditionally put a second insertion caret on screen. Gated on
`frame.focused`, the presentation bit Q#BP14b reserves for exactly this.
UNDERLINES (P2). `build_grid` planned them and nobody consumed them. Straight
forms now ride the quad batch and curly rides the squiggle pipeline, the same
split the terminal path makes for the same reason.
VERSION MISMATCH (P2). The daemon reported the advertised baseline as the
server version while its own `PROTOCOL_VERSION` is 21, contradicting the wire
field's own documentation and inverting the upgrade advice. The field doc now
states what each side can know, and the acceptance is re-pinned — it had been
holding the wrong value in place.
Two gaps the audit found beyond the six, same shape:
* the headless probe never armed the panel wire at all, so no probe could
ever exercise a band;
* a disconnect left the band on screen — the frozen, live-looking surface
the terminal arm already refuses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
All four findings were the same shape: a failure that left state wrong
and told nobody.
**Delete refusals reach the user.** `reconcile_delete_and_fire` returned
`kept_modified` and `refused` and both production callers discarded
them, so a last-buffer refusal or the asynchronous modified-buffer race
left the file gone and the buffer still bound to it — and the next
`C-x C-s` recreates the deleted file. Reporting now happens inside the
shared seam, not at its call sites, for the same reason the
reconciliation does: a caller that has to remember to report is a caller
that will forget. The message names the buffers (capped, with a count
for the rest) and states the consequence, and it is written to
`EditorCore::status`, not `pmacs.error` — that channel is defined only
by a test stub, so a report there would be the same silence.
`reconcile_delete` now prefixes `kill_buffer`'s reason with the buffer
name, because "cannot kill the last remaining buffer" does not say which
buffer is now bound to a deleted path.
**The LSP subscribers stop swallowing their own failures.** Ignored
`pcall`s around `did_close`, `forget_uri`, `did_open` and overlay
re-rooting made the callback return successfully, so the
`all-must-succeed` logger had nothing to log — concretely, a stale server
made `forget_uri` raise while the callback carried on with the old
stores, routes and `documents` entry all live. A shared failure sink
attributes each step, reports on both channels, and raises **after** the
loop, so one unreachable server cannot leave every other attachment
unreconciled.
**`forget_uri` abandons requests through the established path.** It
purged `pending_routes` and `pending_external` but not the same ids
`send_request` put in `LspClient.pending`, and recorded nothing in
`cancelled_rids`. The per-rid work is extracted from
`drain_cancelled_externals` as `abandon_request` and reused, rather than
a second incomplete copy: route, client pending, cancelled record and
`$/cancelRequest` now happen together.
**Acceptance 35 is pinned.** With a plain delete the forbidden fallback
was unobservable — `find_or_open` raises out of `load_file` and the
`pcall` swallows it — so both assertions passed with the fallback
present. The plan now deletes the origin's file and recreates it, which
gives the fallback something to open and makes "restores nothing"
falsifiable. The corrected G1 explanation also reaches the production
comments, which still repeated the false `resolve_target_buffer::NotFound`
story.
New pins: acceptance 53 and 53b assert the status channel; a stale-server
row asserts attribution on both channels *and* that the healthy
attachment still reconciles; an `lsp.rs` unit test asserts the client-side
abandonment with an unrelated request as its control.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
CI falsified rev 2 of the framing. The synthetic reproduction used
`sh -c 'cat <&0 & exit 0'`, and `<&0` does not defeat the POSIX rule it
was chosen to defeat: /dev/null is assigned to an asynchronous list's
stdin *before any explicit redirections*, so by the time `<&0` runs, fd 0
already IS /dev/null and the redirect duplicates it onto itself. bash
happens to skip the default when a stdin redirect is present; dash --
Ubuntu's /bin/sh, and CI's -- does not. It passed locally and failed on
three CI legs.
Control 2 caught it and named its own cause. That is the fourth vacuous
reproduction in this lane and the first found by a control rather than by
a reviewer -- which is the argument for the controls, so the lesson is
recorded that way in the handoff.
The reproduction now uses `setsid --fork cat`: it forks, the parent
exits, and the child inherits stdin/stdout/stderr untouched. No shell, no
asynchronous list, no /dev/null rule, no implementation variance.
setsid(1) presence is asserted rather than skipped -- a skip would
reintroduce the silent-green shape the arming lane removed.
The fix under test is unchanged. Bite re-verified by revert on the new
form: ok in 2.03s with `stdin.take()`, FAILED at 10.00s on the
recv_timeout without it, both controls passing first.
Also adds bottom_panel_stage1_acceptance to the framing's Bet 2 falsifier
list. It holds PTY-in-panel tests and its absence from rev 1 was a real
gap, not a judgement call.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
`RuntimeHandles::drop` joined its reader threads in the `Drop` body,
which runs before any field drops. The `ChildStdin` sink lives inside
`StdinWriter` in the `stdin` FIELD, so it could only be released after
the join returned -- and the join was waiting on readers blocked in
`read()` on pipes whose write ends the child still held, because the
child never received the stdin EOF that would have made it exit.
A closed cycle, entirely inside one function. Teardown hung forever.
This is the root cause of `m4_5_basedpyright_initializes_and_negotiates_
encoding` hanging indefinitely -- diagnosed with gdb stacks plus /proc fd
forensics on a wedged process, reproduced 5/5 deterministically. It also
explains why the hang looked intermittent and machine-local: a
shim-launched server orphans its real process (basedpyright's console
script spawns bundled `node` and exits, leaving it at `PPid 1`), so
nothing teardown signals can reach it, while a direct binary like clangd
or gopls is a genuine child whose pipes close on reap.
`spawn_reader`'s `cancel` flag does not help: it is consulted between
reads and around `send_timeout`, never while `read` is blocked. The
existing comment's premise -- "dropping the master closes the kernel pipe
and unblocks `read`" -- holds for a PTY master but not for pipe mode,
where `read` returns only once *every* write end closes.
The fix reuses `close_stdin`'s existing, already-idempotent mechanism at
the one site missing it. Reordering the struct's fields cannot work: a
type's `Drop::drop` body runs before all of its fields regardless of
declaration order.
Bounded claim: this delivers EOF, so it fixes children that drain stdin
to EOF -- which stdio language servers do. A child that ignores EOF, or
that stops draining while bytes are queued (the writer's `write_all` is
blocking), still wedges the join. Making the `read` itself cancellable
via the poll path already used by `spawn_group_reader` is the standing
deferral that covers those, and is deliberately not in this change.
Test: `teardown_closes_stdin_before_joining_readers`, in `--lib` so it
runs in the standard gate. It models the real shape with an orphaned
grandchild, and carries two positive controls, because this lane wrote
three reproductions that passed against the unfixed tree before one
bit. The `<&0` redirect is load-bearing: POSIX XCU 2.9.3 assigns
`/dev/null` to an asynchronous list's stdin when job control is off, so a
bare `cat &` exits immediately and proves nothing. Teardown runs on a
worker thread behind `recv_timeout` so a regression FAILS in 10s rather
than hanging -- a hanging test would reproduce the hazard being removed.
Bite verified by revert: with the fix `ok` in 2.03s; with the single
`stdin.take()` line commented out, FAILED at 10.00s on the timeout, both
controls having passed first.
Docs: framing doc added; handoff gains the drop-body-before-fields lesson
and the reproduction-needs-a-control generalization, and its section 3
caveat is corrected -- the desktop's basedpyright binary was never
broken. The `--skip basedpyright` gate entry stays for now; dropping it
is a separate proposal owed evidence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Running the bites found three that did not falsify anything.
Item 28's rename row cannot pin the walk's containment rule:
`reconcile_rename` calls `Path::strip_prefix` to rebuild a descendant's
tail, and that is component-aware too, so a string-prefix walk is
silently corrected a second time. Deletion has no such second guard —
the walk's verdict IS the kill list — so the row moves there, and a
string prefix now provably destroys a buffer on `foobar.txt` when
`foo/` is deleted.
Item 30's composition-order assertion was a tautology: the LSP attach
leaves `diagnostic` LAST in the stack, and moving the last element to
the end is a no-op, so a remove-and-re-push was indistinguishable from
an in-place mutation. The row now pushes one more overlay after it and
asserts that precondition explicitly.
Item 34 needed both a restructure and a correction. §5's G1 says a
stale captured path "materializes a phantom" via
`resolve_target_buffer`'s `NotFound` arm.
It does not: `pmacs.buffer.find_or_open` calls `file_io::load_file`
directly and maps the error, so a missing path RAISES, and the
`NotFound` arm belongs to `resolve_target_buffer`, which serves
`pmacs.window.display_file` and the startup target rather than this
binding. The real defect is smaller and still real — the `pcall`
swallows the raise and the user is stranded wherever the last applied
op left them — so the plan now edits another file first, which is what
makes the restore observable at all. The correction is recorded at the
test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
`tests/resource_reconciliation_acceptance.rs`, 23 rows, no dired
content — items 23–37 and 50–55 driven through the real entry points:
`pmacs.fs.rename` / `pmacs.fs.remove` fire-and-forget for the drain
harvest, `pmacs.buffer.apply_resource_op` for the synchronous arm, and
the fake server's `workspace/applyEdit` for the applier.
The rows that took design rather than transcription:
Item 27 opens two descendants AND two buffers on one exact path, since
one child would not defeat a first-match lookup. Item 29 tests name
provenance in both directions, including a name explicitly set to a
string that normalizes to the file's own path — the case a
path-equivalence heuristic gets wrong. Item 30 paints a real frame and
counts diagnostic underlines per window rect, because
`DiagnosticView.uri` is private and a store assertion would prove
nothing about re-rooting; it also pins each overlay's index in the
composition order, which is what a remove-and-re-push breaks. Item 53b
states its three assertions individually, since a compound check can
pass on two of the three.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Bottom-panel Stage 2B-3, part 2 of 3: the pixel substrate for the band.
`text_area_bottom` was three boundaries wearing one name — its own doc
comment called it "the single source for every bottom-of-text
computation" — and once a band can be installed they must diverge:
status_band_top = max(0, height - status_band_height)
geometry_capacity_bottom = max(0, status_band_top - divider_height)
document_text_bottom = max(0, status_band_top - installed_band)
The census is 29 matches: 20 production call sites, 1 definition, 8 test
sites. All 20 were read in their enclosing function and classified
individually — 8 status-owned, 12 document-owned. A blanket rewrite that
subtracted the band from all of them would move the status chrome with
the document and pass an "everything moved" assertion, which is why the
classification is per site and the criterion asserts both directions.
The three easiest to get wrong keep their named symptoms: document
completion placement is document-owned (status-owned would overlap the
band), minibuffer candidate clipping is status-owned (the minibuffer is
global bufferless chrome anchored to the band, and clipping it at the
document boundary would cut it off), and edge scrolling is document-owned
(left on the old bottom it would auto-scroll from inside the panel).
`geometry_capacity_bottom` reserves the divider even while the panel is
absent. That asymmetry is what breaks the first-open cycle: the daemon
sizes a panel from the capacity it was told about, so a capacity that
ignored the divider would grant a first panel that does not fit once the
divider appears beside it. The document loses no pixels until a `Present`
frame is really on screen.
`PanelBandInset` is a newtype, not an `f32`, because three boundaries here
take a pixel height and only one takes this one.
Alongside it, the band's own machinery: `PanelBand` with ONE derivation of
"is a panel on screen" (`presented()` — retained valid frame, matching
geometry epoch, latch clear), the frontend-owned epoch state machine with
its fail-closed exhaustion latch, the `Absent`-is-authoritative receipt
path, `panel_cell_capacity` (no per-axis cap — a panel may legitimately be
wider than a PTY — plus the daemon's virtual status row), the stable
normal-face probe for column count, and the divider strip whose paint rect
IS its hit rect.
`TerminalPaintPlan::build_grid` factors the shared cell planner so a panel
and a terminal cannot disagree about a wide-continuation pair; terminal
selection spans stay outside it rather than being faked as empty inside.
`PANEL_MIN_VERSION` moves into `pmacs-protocol` so the GPU frontend aliases
one definition instead of restating 21.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
`src/lsp.rs` gains nine: the fourteen-family store inventory with a
17-entry precondition so it cannot pass vacuously, the route purge with
`workspace/symbol` and another server's route both surviving, the
awaiter drain joined on the rid, the error contract's two arms, the
late-publish drop with its does-not-over-reach companion, the
`mark_document_stale` gate across all three stale stores, exact-pair
tombstone identity, and reclamation under both `start_generation` and
terminal `forget`.
`src/diag.rs` pins that `forget` drops the epoch while `clear`
deliberately bumps it — the leak a `clear`-based forget would leave in
the one map nothing prunes.
`src/async_runtime.rs` injects two resource replies onto the private bus
in each order and asserts `TickOutcome.resources` reports arrival order,
not allocation order; plus that a failed or cancelled mutation is not
harvested at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
One shared walk query (`buffers_bound_under`), lifted out of #190's
`delete_verdict` so the guard and both reconciliation seams cannot
disagree about which buffers an operation touches: every buffer, both
sides normalized, component-aware containment.
`EditorCore::reconcile_rename` moves the stored path and — only for a
`PathDerived` name — the buffer name. `EditorCore::reconcile_delete`
composes the same two removal phases `pmacs.buffer.kill` composes,
preflighting `editing_in_progress` because a `ConcurrentEdit` refusal
arrives after `kill_buffer` has already moved windows. Phase 2 stays
with the caller; `EditorCore` gains no Lua handle.
`AsyncRuntime::tick` now returns a `TickOutcome` carrying the settled
ids plus the successful resource mutations, in bus-arrival order, which
is documented as not being execution order. `PendingJob.resource`
retains the paths the dispatchers move into the worker closure.
`LspManager::forget_uri` purges the routes carrying a URI, drains the
awaiters joined to them on the rid, and clears all fourteen stores plus
`documents`. A generation-scoped exact-pair tombstone gates the two
uncorrelated writers that can otherwise resurrect what it cleared:
`publishDiagnostics` and `mark_document_stale`, which now takes a
server id. `ResponseRoute::scoped_uri` is the one variant list, with
`uri()` delegating to it.
New Lua surface: `pmacs.buffer.set_name`, `pmacs.lsp.forget_uri`,
`pmacs.diag._rename_resource`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Bottom-panel Stage 2B-3, part 1 of 3: the compatibility-preserving v21
activation mechanism and the negotiated `panel_capable` flip.
2B-1 reserved the v21 wire and 2B-2 built the daemon projection behind
it, both dark, because the handshake is server-first: the daemon writes
`Hello` before the frontend has said anything, and a frontend rejects a
`protocol_version` outside its supported range *before* it can send
`AttachRequest`. Advertising 21 there is therefore an incompatible act on
its own, independent of whether one new message is ever exchanged.
So the advertised version does not move. `ADVERTISED_PROTOCOL_VERSION`
becomes a permanent compatibility BASELINE, and the session's real
version is settled one message later, by the frontend:
1. the daemon advertises the baseline (20, unchanged);
2. the frontend answers `requested_protocol_version(baseline)` — its
own `PROTOCOL_VERSION` when the baseline is the current one, and a
verbatim echo of anything older;
3. the daemon records `negotiated_session_version(offer)`.
A shipped v20 frontend echoes 20 and gets a v20 session, byte-for-byte
as before — the real-daemon acceptance that emulates its rejection point
still passes untouched. A current frontend offers up and gets v21. The
`Hello` encoding and value are unchanged, which is why the old frontend
never sees a version it must reject.
`peer_declared_panel_support` gains the arm 2B-2 deliberately left off:
a semantic session is panel-capable exactly when it negotiated
`PANEL_MIN_VERSION` or later. The gate is on placement, not only
transport, so a v6-v20 semantic session keeps the Stage 1 fallback.
The GPU client's `server_protocol_version` splits into
`session_protocol_version` (what the session speaks — every wire gate
keys on this) and `baseline_protocol_version` (what `Hello` advertised).
They now differ in the normal case, and that difference IS the
compatibility property, so both headless probe reports emit both keys and
the two ratchets that read them assert both directions: session 21 AND
baseline 20. Asserting only the session version would pass if the
baseline had been bumped too — the exact incompatible change this
mechanism avoids.
Also fixes a pre-existing `unused_mut` in a `crdt`-gated daemon test,
dark to the standard clippy gate because that gate runs without the
feature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
`BufferNameOrigin` records where a buffer's name came from instead of
inferring it from the string: a path-backed buffer's name is the path
*as given*, so a relative open is named `foo.rs` while its stored path
is absolute, and a user may legitimately choose a name that normalizes
to its own file's path. Rename reconciliation asks the bit.
Every path-backed creation site is audited onto the new
`set_path_derived_name` door: `EditorCore::get_or_load_buffer`, the
`NotFound` arm of `resolve_target_buffer`, `pmacs.buffer.from_file`,
and `pmacs.buffer.find_or_open`. Ordinary `Buffer::set_name` records
`Explicit`.
`View::rename_resource` is the seam that re-roots a URI-keyed overlay
in place, so it keeps its position in the window's composition order;
`DiagnosticView` overrides it, whose `uri` is private and set once at
construction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
One conflict, `docs/active-work.md`, with three strands rather than the
usual one: main gained #190's lane, this branch carries its own Stage 1
lane and a relabel of the #188 framing lane, and main had removed the
documentation lane while this branch still had it.
Resolved by construction. Main's file taken whole; this branch's Stage
1 lane reinserted at its own position ahead of the bottom-panel lane;
this branch's relabelled framing lane ("MERGED AS PR #188") kept in
place of main's stale "OPEN, PROPOSED" version; main's removal of the
documentation lane preserved.
Verified against both parents rather than by inspection: the Stage 1
block is byte-identical to this branch's, the documentation lane is
gone, no conflict markers survive, and the update-protocol rule 6 seam
check finds no double blanks.
Note for whoever absorbs next: main now carries three lanes describing
merged PRs (#190, #188, #194). This merge keeps this branch's more
accurate labelling of the #188 one but does not remove any of them ---
rule 4 permits removal only once durable facts reach
`docs/agent-handoff.md`, and none of those three PRs touched it.
Adopt Q#GB6's clamp-or-clear rule in both window-coordinate
normalization paths. Preserve shortened selections, clear only those
collapsed by a moved endpoint, and pin both outcomes through the real
generated-write and view-rebuild callers.
Make listview refresh rely on the generated-write notification before
reseating, so Stage 1 criterion 7's fan-out mutation bites both
adopters. Align criteria 5, 11, and 12 with framing revision 7.
Four findings, all reproduced by the reviewer, all accepted. Two of
them are one defect class — a guard whose scope was REASONED ABOUT
rather than enumerated — so both are recorded in the framing's new §9
together with a sweep of every other place this lane decides something
is "affected".
P1 — the delete preflight broke ordered resource operations
(§9.3). Every delete was judged against the filesystem's INITIAL
state, at plan-construction time, so a valid `create X -> delete X`
was refused with a fabricated NotFound about a path the batch was
about to create; likewise `rename A -> B -> delete B`. This was a
regression this lane introduced, not a pre-existing defect.
Decision: DEFER, do not simulate. A delete whose target is related by
component-aware path containment to a path an EARLIER op in the same
plan creates, renames onto, renames away from, or removes is not
judged at plan time; the primitive judges it when it runs. Q#RD3
already calls this check a filter, not a transaction, so declining to
judge is inside its contract and refusing a legal batch is not.
Simulating instead would mean modelling filesystem presence AND the
registry's path bindings across create/rename/edit — the transaction
Q#RD3 declines to build — and a wrong simulation emits false `clear`
verdicts, which is the dangerous direction. `edit` ops are excluded
from the deferral set on purpose: an edit changes no path's existence,
so it can only turn a plan-time `clear` into a primitive-time
refusal, which Q#RD3 already documents and accepts. The
buffer-and-filesystem half therefore still fires early for any target
no prior op touches, which is what criterion 11c pins.
P1 — the production-boundary acceptances are landed (§9.5).
Criteria 11, 11a-11d, 12 (both directions), 13 and 15 now drive a real
`pmacs_fake_lsp` child over a real transport. One parameterized mode,
`applyeditplan`, replaces the eight the framing named: it reads its
whole WorkspaceEdit from a test-written file and publishes the
client's response to a sink, so each of the eight fixtures sits next
to the assertions that depend on it instead of being mirrored across
two files. Fail-closed — an unreadable plan sends no applyEdit and
reports itself through the sink, so a broken fixture cannot read as a
pass — and the sink is written `.part`-then-rename so a polling reader
never sees a partial record. There is no skip-and-return-ok arm
anywhere: `fake_lsp_path` resolves through `env!("CARGO_BIN_EXE_...")`,
a compile-time constant, so a missing binary is a build failure.
P1 — mid-batch failures were misreported as complete aborts (§9.4).
`apply_workspace_edit` now returns `nil, message, applied_op_count`,
and ONE renderer serves both the user-facing status line and the
server's `failureReason`, so the two cannot disagree. All three
callers are updated, not only the rename one.
P2 — non-recursive deletes inspected descendants (§9.2). `recursive`
is now a parameter of the shared query and descendant matching is
reserved for recursive deletes. The old doc comment argued at length
for the wrong behaviour and is replaced by the counterexample that
falsifies it: a modified buffer at `tree/gone.rs` whose file is
already gone blocked a non-recursive delete of the now-EMPTY `tree/`,
an op that would have succeeded and that removes none of that
buffer's contents. This narrows the Q#RD6 query #171 adopts.
Criterion 3's stated bite: fixed by fixing the SETUP, not the doc.
The first commit's test comment carried a correction saying the
framing's wording was wrong. It was wrong only against that setup —
and §9.2's narrowing would then have left the setup with no bite at
all, since a non-recursive delete no longer inspects a descendant.
So the buffer is now bound to the EXACT deleted path: a file is
opened, then replaced on disk by a non-empty directory, and
`remove_dir` fails with ENOTEMPTY deterministically under any uid.
Both of the framing's stated pre-images now bite, so the framing
needed no amendment there. The correction is recorded in §9.1 rather
than only in a test comment, which is where the review asked for it.
WHY THE SHIPPED SUITE PASSED WHILE FINDINGS 1 AND 4 WERE LIVE — two
coverage facts for the next lane. Every delete criterion drove the
PRIMITIVE directly, so nothing in the suite ever built a multi-op plan
and the preflight's plan-time behaviour had no test at all; the only
batch test, `m4_15`, happens to delete a path no earlier op touches.
And every recursive-delete criterion (7, 8, 9) passes `recursive =
true`, while every non-recursive one binds its buffer to the exact
target, so no test in the suite ever combined a non-recursive delete
with a descendant buffer — the exact cell finding 4 lives in.
Sweep, per the review's request. Seven sites decide something is
"affected"; the table is in framing §9.7. Three were the defects
above. Two are unchanged by design and named so they are not mistaken
for oversights: phase-4 reconciliation compares paths RAW via
`BufferRegistry::find_by_path`, which Q#RD10 pins as "exactly today's
behaviour" and which correcting would widen reconciliation — the one
thing Q#RD5 forbids; and `delete_verdict` stats the raw path while
comparing normalized ones, a latent inconsistency whose every branch
fails safe and which matches the primitive's own `remove_file`. Two
are consistent: the `_delete_verdict` binding defaults `recursive` and
`ignore_if_not_exists` the same way the primitive does, and the
deferral set is enumerated (create: 1 path; rename: 2; delete: 1;
edit: excluded, with the argument written down) rather than reasoned
about. Nothing else in the lane decides an affected set.
Bites. Every row was RUN, with the positive control `scripts/bite`
gained in #192 (merged into this lane), and every ref-based row below
reports `OK (assertion)` rather than `OK (COMPILE)`. `1873be6` is this
lane's own first commit: findings 1, 3 and 4 were introduced by it, so
`main` cannot falsify their pins.
rd11a builtin/runtime/lsp.lua @ main OK (assertion)
rd11b builtin/runtime/lsp.lua @ main OK (assertion)
rd11c builtin/runtime/lsp.lua @ main OK (assertion)
rd11d builtin/runtime/lsp.lua @ main OK (assertion)
rd12a builtin/runtime/lsp.lua @ main OK (assertion)
rd12b builtin/runtime/lsp.lua @ main OK (assertion)
rd13 builtin/runtime/lsp.lua @ main OK (assertion)
rd15 builtin/runtime/lsp.lua @ main OK (assertion)
rd18 src/lua_bindings/mod.rs @ 1873be6 OK (assertion)
rd19a builtin/runtime/lsp.lua @ 1873be6 OK (assertion)
rd19b builtin/runtime/lsp.lua @ 1873be6 OK (assertion)
rd19c builtin/runtime/lsp.lua @ 1873be6 OK (assertion)
rd20 builtin/runtime/lsp.lua @ 1873be6 OK (assertion)
Two rows need their weakness stated rather than hidden.
rd11 is VACUOUS against `main`'s `lsp.lua` and the script says so — a
preflight-less applier passes it, which is expected, because rd11 is
the direction that asserts the guard does NOT over-refuse (the same
shape as criteria 2, 7, 9 and 14). It bites two other ways, both run:
`OK (assertion)` against `main`'s `src/lua_bindings/mod.rs`, where the
primitive's absent-plus-ignore branch destroys the buffer; and against
a hand mutation dropping `ignore_if_not_exists` from the preflight
call, which is the pre-image the framing actually names for it.
rd3's two pre-images are designs never committed, so no ref carries
them and `scripts/bite` cannot be used. Hand-mutated instead:
reconciliation moved ahead of the filesystem mutation makes rd3 fail
on exactly its stated assertion (and rd4 with it). On this setup that
mutation and "validation that removes rather than inspects" are the
same mutation, because the buffer is bound to the exact deleted path —
stated because the first shipped setup could see neither.
The eight rows against `main`'s `lsp.lua` all fail by TIMEOUT rather
than by a value assertion, and that is the pre-image behaviour, not a
flaky harness: on `main` the primitive's raise escapes the applier,
escapes `handle_server_requests`, is swallowed by the
`pcall(handle_server_requests)` at the bottom of the file, and the
server is never answered at all. The sink is therefore never written.
That unanswered request is the defect criterion 13 exists to pin.
Gates: fmt; clippy -D warnings; --lib 1863; --lib --features crdt
2048; m4_acceptance 146 (was 132); lsp_dispatch_seams_acceptance 15;
dired_acceptance 25 and autosave_acceptance 29 (the framing's watch
items); PMACS_REQUIRE_GPU=1 -p pmacs-gpu 202; git diff --check clean.
No protocol change.
Review finding 1 on PR #191. `notify_buffer_edit` clamped `cursor` and
`view_top` but not `win.selection.anchor`, and `rebuild_views_for` had
the same gap. Clamping the cursor is not enough to make the region safe:
`Window::region` orders `(anchor, cursor)`, so a stale anchor above a
clamped cursor is still the region's high end and `region_bytes` slices
the rope with it. Reproduced before the fix as
`assertion failed: end <= self.len()` at `src/rope.rs:145`, reached from
`EditorCore::clipboard_copy` after a generated rewrite.
The anchor is DROPPED, not clamped. A window must always have a cursor,
so clamping one is the only available answer; a window need not have a
selection, and a clamped anchor asserts a region boundary the user never
placed --- after a wholesale rewrite the surviving offsets address
unrelated bytes. This is not a new rule: `window.quit`'s restore already
answers the same question the same way with
`selection.filter(|sel| sel.anchor <= len)` (`src/editor_core.rs:3259`).
One rule, now three call sites.
Both exits are pinned separately, because fixing one and trusting the
other is how the gap arose: `acc16h` drives `notify_buffer_edit` through
a generated write, `acc16i` drives `rebuild_views_for` through
`pmacs.help.show_command`, which is the `*help*` renderer's real path.
Deleting either call site fails only its own test. The pin also
discriminates DROP from CLAMP, because that is the decision a revised
Q#GB6 could overturn.
The wording is marked PROVISIONAL in both the implementation and the
pins. The rule belongs to Q#GB6, and PR #188's approved revision 5 does
not mention the anchor; a revision request carrying this defect is with
that lane. If the landed revision says clamp or translate, this changes
to match rather than standing as a third description.
Also in this commit, review findings 2 and 3 --- the tree asserting what
the record does not support:
- Criterion 5's restatement is withdrawn in BOTH suites. The tests now
quote the approved criterion, are renamed `*_provisional_*`, and say
they do not satisfy it; the evidence (`ensure_writable` precedes the
intercept chain, with the measured `ReadOnly` message) is recorded as
what was sent to #188, not as a replacement contract. The framing's
own bite is unchanged and still fails them.
- Criterion 7's "for each adopter" is restored: the listview half now
exists as its own test. Its inability to carry the framing's mutation
bite --- `window.switch_buffer` rebuilds the `TextView`, verified by
applying the mutation and watching this half stay green while the
dired half fails --- is recorded in the test and filed with #188,
not resolved here.
- Criteria 11 and 12 are relabelled from `main` bites to mutation bites.
Both fail on `main` only at their disambiguation premise and never
reach the assertions they exist for, so a revert is not evidence for
what they assert.
How a restated contract passed the previous gate run, since the next
lane can use this: nothing in the gate suite reads a framing document,
so a test that quietly narrows its criterion is indistinguishable from
one that satisfies it --- both are green, and `scripts/bite` only proves
an assertion bites some pre-image, never that the assertion is the one
that was approved. The gate can catch a test that does not bite; it
cannot catch a test that bites the wrong contract, so that check has to
happen where the criterion is read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Stage 1 of generated-buffer immutability
(docs/generated-buffer-immutability-framing.md, revision 5). Closes the
two families the bug is reachable on WITHOUT `M-x`: `compile.lua` and the
search panel rebind all seven undo chords to a no-op, but `dired.lua` and
`listview.lua` rebind nothing, so a bare `C-/` emptied a listing and a
panel. The cheap half is also the exposed half.
An intercept is not read-only. `Buffer::undo` reaches the rope through
`ensure_writable` and never consults the intercept chain, so the
erroring-intercept-plus-`bypass_intercept`-over-a-writable-rope idiom
guarded the edit path and left the history path open. Rebinding chords
does not close it: `M-x buffer.undo` is dispatchable on every buffer in
the tree.
- `dired.lua`'s `paint` and `listview.lua`'s `render` write through
`pmacs.buffer.set_generated_contents` — lift the lock, whole-buffer
replace skipping intercepts, discard history, re-assert the lock, fan
the `Edit` out. Zero `bypass_intercept` writes remain in either file.
- Both keep their named erroring intercept and `set_round_trip_input`.
The layering at `terminal.lua:351-366` is unchanged: the rope lock
protects the daemon copy, round-trip input protects a semantic
frontend's own mirror, and neither substitutes for the other.
- Q#GB13 — `listview.ensure_panel` stops adopting a same-named foreign
buffer. Ownership is the `panels` table; a collision disambiguates
`<2>`..`<99>` and raises at the limit, matching `dired.lua:476-504`.
This is a prerequisite of the lock, not a follow-up: the arc removes
the `M-x buffer.undo` that was the only recovery from a clobber.
- Q#GB18 — `panels` becomes a compacting list keyed by identity. It was
written under the requested name and read back under the actual name,
which a disambiguated panel breaks: `RET`, `g` and `q` fail closed and
silently, and `listview.open`'s capture guard fails OPEN, capturing a
panel as its own `q` target — the chained-panel loop its comment says
it prevents. Ships in the same commit as the disambiguation by the
framing's ordering constraint.
- Q#GB6 — `EditorCore::notify_buffer_edit` clamps each window coordinate
against its own post-edit bound, unconditionally. `cursor` is a byte
position bounded by `Buffer::len`; `view_top` is a line index bounded
by `TextView::line_count`, and a replace can grow in bytes while
collapsing lines, so "the buffer shrank" is not a usable trigger. This
fixes a shipped defect that reaches terminal copy mode.
- Q#GB16(a) — locking these families disables fold CREATION on them,
because `document_bytes` is spelled `is_read_only()`. Accepted and
stated rather than shipped silently; the status string now names the
read-only lock instead of claiming "not a document buffer".
Acceptance: 10 new criteria in `listview_acceptance` (16 total), 6 in
`dired_acceptance` (31 total), 2 in `terminal_copy_mode_acceptance`.
Every criterion's falsifying mutation was run: 5 bite by revert against
`githubsucks/main`, 9 by a named one-line mutation.
Two framing corrections, both recorded in the tests rather than worked
around silently:
- Stage 1 criterion 5 is unreachable as written. `Buffer::apply_edit`
(`src/buffer.rs:773`) and `begin_edit` (`:725`) call `ensure_writable`
as their FIRST statement while the intercept chain runs later inside
`apply_edit_inner` (`:1072`), so once this arc's lock is installed an
ordinary edit can never reach the intercept. Restated at the one point
where the two are distinguishable — the lock lifted — which is the
state the intercept genuinely still covers.
- Criterion 7 cannot bite at the listview adopter. `listview.refresh`
and `listview.open` both follow `render` with `window.switch_buffer`,
which rebuilds the `TextView` from scratch and masks a dropped
fan-out. `dired.revert` does not, so the dired half carries the bite;
it fails under the mutation with the reported
`assertion failed: end <= self.len()`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Implements the framing merged as #186. On `main` today,
`pmacs.buffer.apply_resource_op`'s delete arm removes a file and then
removes any buffer bound to it, with no dirty check at any link in the
chain — so a server-driven delete destroys unsaved edits, and the
`ignore_if_not_exists` arm destroys them having done no filesystem work
at all.
Layer 1 — the primitive. The delete arm becomes four ordered phases:
stat/no-op decision, enumerate and validate, mutate the filesystem,
reconcile the registry. Validation inspects and removes nothing, so a
filesystem failure leaves every buffer intact automatically rather than
by compensation, and `on_removed` still observes the path already gone
because reconciliation stays last.
`delete_verdict` is the single shared query. It scans *every*
path-bound buffer rather than the first match, because `find_by_path`
is first-match-only and `pmacs.buffer.from_file` makes duplicates
reachable — a clean first match could otherwise hide a modified second.
It normalizes both sides before comparing and uses component-aware
`starts_with`, so `/tree` does not match `/tree-sibling`. It stats with
`symlink_metadata`, not `canonicalize`, which reports a dangling
symlink as absent and would disagree with the primitive on exactly the
input `ignore_if_not_exists` turns on.
Layer 2 — the applier and the server-request boundary.
`apply_workspace_edit` gains a plan-time delete precondition check
driven by the same Rust helper, so the two layers cannot drift. It is a
filter, not a transaction, and the code says so: `documentChanges` are
sequential, so an earlier edit can dirty a buffer a later op deletes.
The applier is now total — every failure becomes `nil, message`, and
the origin buffer is restored on the failure path as well as the
success path. At the boundary, parse *and* apply are wrapped:
`_parse_workspace_edit` sits one line above the applier and is
fallible, so a parse failure previously escaped, was swallowed by
`pcall(handle_server_requests)`, and left the server unanswered — the
defect being fixed, one line out of scope. Failures now also append one
labelled record to `*errors*`.
Scope, stated plainly rather than implied by what is present:
* Acceptance criteria 1-10, 14 and 16 land here — 11 tests driving
the primitive directly. Criteria 11, 11a-11d, 12, 13 and 15 do
NOT: they exercise Layer 2 through a real server pump and need
`pmacs_fake_lsp` modes that do not exist yet. Criterion 13
explicitly rejects a direct-call test as insufficient, so the
Layer 2 code currently has no production-path pin. That is a real
gap and the reason this is not the whole lane.
* The framing's §8 branch plan said the implementation would land on
#186 itself. #186 merged as framing-only, so it gets its own
branch and PR. No decision changes.
* Criterion 3's stated bite in the framing is wrong. It claims to
fail against buffer-first ordering; it does not, because the
deleted path is a directory no buffer is bound to, so the
reordering never fires on that input. It does fail against
validation that removes rather than inspects. Checked by mutation
rather than trusted, and the test comment carries the correction.
Bite: criteria 1, 5, 6, 8 and 10 fail against `githubsucks/main` under
`scripts/bite`. Criteria 3 and 4 pin phase ordering against designs
never committed, so `main` cannot falsify them; both were verified by
hand mutation instead. Criteria 2, 7, 9 and 14 assert preserved or
deliberately-unchanged behaviour and pass against `main` by design —
2 is criterion 1's opposite direction, 9 pins today's imperfect
orphaning so widening cannot happen silently.
Gates: fmt; clippy -D warnings; --lib 1863; --lib --features crdt
2048; m4_acceptance 132; lsp_dispatch_seams_acceptance 15;
dired_acceptance 25 and autosave_acceptance 29 (the framing's watch
items); PMACS_REQUIRE_GPU=1 -p pmacs-gpu 202; git diff --check clean.
No protocol change.
Retain panel statusline segments only for the exact side-window and
buffer presentation that published them. Clear that baseline whenever
the daemon publishes authoritative Absent.
Pin both transitions: same-window buffer replacement under NoMessage,
and Absent-to-Present under NoMessage.
All five review findings reproduced with a failing test before any fix,
and every fix falsified by reverting it.
R1-1 — the wire-area clamp lived only in `panel_grid_size`, so the daemon
shipped an authoritative `Absent` while `panel_hidden` stayed false:
keys kept reaching the invisible window and a panel terminal kept its
controller. Q#BP2b calls hiding a DURABLE state transition and the
exhaustion arm had made it a per-frame effect. Fixed structurally rather
than pointwise: `presentable_panel_grid` is now the one derivation behind
both the renderer and `reconcile_panel_layout_core`, so the two cannot
drift apart again.
R1-2 — closing and reopening the same PERSISTENT buffer inside one
dispatcher burst left the shipped declaration intact while the window it
described was already dead, and same-buffer/same-size made the successor
indistinguishable by every other field. A presentation epoch only
identifies a presentation if something checks that the presentation it
names is still on screen, so `panel_declaration_matches` now takes the
live side window and buffer.
R1-3 — the semantic terminal-layout twin consulted only the full-document
declaration, which a panel terminal deliberately lacks, so the child kept
its opening geometry through the drain. `sync_semantic_panel_terminal_
layout` is the missing case; it resolves through `side_window_for` while
its sibling resolves through `primary_document_window`, so the two are
disjoint by construction and nothing is resized twice per tick.
R2-4 — `NoMessage` means publish nothing, not publish empty. Treating it
like `Invalidated` removed the band's provider text on a transient
buffer-follow mismatch. The band repaints its whole mode line every
frame, so "publish nothing" has to be a retained baseline; it is keyed by
window id so a replaced panel inherits nothing.
R2-5 — non-`Move` activation is Q#BP16's TERMINAL clause, because the
shared adapter claims the controller for wheel steps too. A document
panel keeps scroll-without-focus, matching `dispatch_mouse`.
The sweep for R1-1's and R1-3's shape found one more, and it is the same
bug as R1-1: a panel wider than the terminal subsystem's per-axis cap is
legal on the wire (Bet B5') but its content rect was refused by
`snapshot_for_view`, collapsing the projection to `None` — a per-frame
`Absent` with the durable state still saying visible, reachable with one
`FrontendCellGeometry` declaration. The band is legitimately that wide,
so the child is clamped to the columns a PTY can have and the remainder
paints as band background, exactly as a narrower snapshot already does.
One knowingly per-frame `Absent` remains and is recorded in the code
rather than fixed: presentation-epoch exhaustion, which takes 2^64
shipped presentation changes in one session and cannot be reached by any
frontend.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Q#BP16 step 3 compares an inbound event's geometry epoch against BOTH
the declaration the frontend was looking at and the daemon's latest
accepted one, and mutation testing showed the second half unpinned: no
fixture made the two diverge, so deleting it changed nothing. They
diverge exactly once — between a declaration being accepted and the
next frame answering it — which is the font/scale/resize race the epoch
exists for.
Also pins the attach/resize gate change at the seam it would break: a
semantic frontend's `Resize` must mint no frame geometry even when its
view is panel-capable, while a grid frontend's real frame size still IS
its declaration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Bottom-panel Stage 2B-2, second half: the producer, the presentation
epoch, and the three inbound event gates.
The producer lives beside the terminal pass in `semantic_render.rs` and
follows its shape: compare the complete payload first, validate only a
payload that differs, and store only what was actually shipped. The
presentation epoch is allocated from the side window and its buffer, so
a new side window, a replaced buffer, and every `Absent` -> `Present`
transition each take a fresh identity; `Absent` clears the identity,
which is what makes close/hide/reopen of the SAME persistent buffer
unaddressable by a stale `PanelPointer`. Allocation is checked and
exhaustion fails closed to `Absent` rather than wrapping into a live
identity. The `Absent` baseline is seeded rather than left empty: a
fresh session has no band, so the opening state is a fact the peer
already holds.
The band rides both render paths and does not wait for a declared byte
viewport: it is a separate surface, and gating it on the document
declaration would leave the first panel unpaintable. Its mode line takes
the side window's segments from the SAME provider invocation that serves
the document's wire segments.
Inbound, `peer_may_send_panel_events` checks four facts together — an
installed semantic projection, the negotiated version, the daemon's own
capability bit, and (via the transport source) that the payload's
claimed id is never consulted. `panel_event_epochs_are_current` then
runs Q#BP16 steps 2-4 as one predicate so no caller can check the
geometry epoch and forget the presentation epoch.
Two daemon gates moved from `panel_capable` to `!semantic_render`. Stage
1 could conflate them because panel capability implied grid; now that a
semantic view can be panel-capable, a capability-keyed gate would feed it
the permanent 24x80 attach placeholder that Q#BP15a forbids, and parent
acceptance 40 would fail through the attach line rather than through the
projection. Not a live defect — no production semantic session is
panel-capable yet — but it is the landmine Stage 2B-3 would have stepped
on.
`panel_capable` is unchanged for production negotiation and the
unsolicited `Hello` still advertises v20. Nothing here is reachable by a
user.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Bottom-panel Stage 2B-2, first half: the daemon-side primitives the
panel producer needs.
`GeometryUpdate` is three-valued rather than a boolean because the
caller must act differently on each arm. `declare_frame_geometry` stays
the grid/LOCAL allocator, keeps value dedup, and moves from
`saturating_add` to checked allocation with a fail-closed exhaustion
arm: it clears the declaration back to unknown, which is already
non-presentable, so reconciliation hides the panel rather than painting
one sized to a frame that no longer exists.
`accept_frame_geometry` is the separate semantic path. No value dedup —
a font or scale change can invalidate a panel frame while `CellSize` is
identical, which is exactly what daemon-side dedup cannot see (Q#BP2S1)
— and a lower epoch is rejected even when it carries identical data.
`panel_grid_size` derives Q#BP15a's third geometry: full declared
width, `fixed_rows` clamped by the recursive document minimum and then
by the shared wire area budget, with the stored request left alone.
`prepare_panel_projection` paints the side window through the Stage 2A
extracted painter, gating folds on the OWNING frontend rather than
`fold_map_for_window`'s active-frontend gate (Q#BP17), and takes the
side window's statusline segments as a parameter so one provider
invocation serves both surfaces. `window_cursor_cell` is `paint_frame`'s
caret derivation lifted out so the band does not become a second,
drifting copy of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Reserve the additive v21 panel schema without advertising it in the
server-first production handshake. Pin a real shipped-v20 client attach,
make the two aggregate-budget ratchets exactly one byte over, and update
the framing, coherence audit, handoff, and volatile lane record.
Integrate the Journey Stage 1a merge without rewriting the already
reviewed protocol branch. Record the approved three-way Stage 2B split,
advance the canonical recovery anchor, remove the landed Journey lane,
and put 2B-1 into its full-gating state.
Completes Journey Stage 1a: the `commit_to` acceptance suite (framing
§6 N4, N6, N6b, N6c, P1, P2, P3) plus the documentation updates
COHERENCE §25 requires the PR to carry.
Bite-testing the new pins found a real gap. Deleting the
`ScopedFrontend` arm from `acting_frontend` left N4 green, because
`ScopedFrontend::enter` also swaps `core.active_frontend` and the
ambient fallback then answers correctly on its own. The arm is
load-bearing in exactly one case — a commit reached from inside an
interactive command, where the origin sits between the override and the
ambient value — and nothing pinned it. N4b is added, driven through
`dispatch_key` because that is the only thing that establishes an
interactive origin, and the mutation now bites it.
Two smaller corrections found the same way:
* `commit_to`'s forged-destination message was unreachable. Typed as
`AnyUserData`, mlua rejected a table during argument conversion, so a
caller got "error converting Lua table to userdata" — true, but naming
neither the rule nor the remedy. The parameter is now `mlua::Value`
and the pointed message fires.
* P1 and P2 also fail on full revert, since `commit_to` does not exist
on the pre-image, so §6.0's "legitimately green on the pre-image" does
not describe them. They stay in the P list because their
discriminating falsifier is the named mutation — a revert-only check
cannot distinguish "validates" from "validates in time" — and each pin
now says so at its own site rather than being silently mislabelled.
Bite results, each run against the whole suite:
scope stops swapping `core.active_frontend` -> N6a, P3 fail; nothing else
preflight moved after the callback -> P1, P2 fail; nothing else
drop the `ScopedFrontend` arm -> N4b fails; nothing else
Docs: COHERENCE §2 grade + step-3 verdict row, §20 Priority 1 and the
arc list; the GPU initial-target framing's Q#GT6 and acceptance 10,
whose directory case this stage deliberately supersedes; handoff §1;
the active-work ledger; framing rev 7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both pins failed on the bump, which is what they exist for. The ladder
test now accepts 6..=21 and rejects 22, and the version assertion carries
the Stage 2 entry: four variants appended after their enum's final v20
variant, gated in both directions.
Also renames `protocol_version_is_twenty_for_gpu_initial_targets`, whose
name pinned the old number.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR
Adds the four wire shapes Q#BP9 names, bumps the protocol to v21, and
factors the cell-grid validator so a panel frame shares the terminal's
rules without inheriting its PTY caps.
- `InstanceMessage::PanelFrame(PanelFramePayload)`, appended after
`InitialTargetResult`; `Absent` is an explicit authoritative state, not
silence, because the receiver retains its last valid frame.
- `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`,
appended after `TerminalPointer`. Geometry is valid without a side
window — gating it on panel presence would deadlock the first open,
since the daemon needs columns before it can paint a first frame.
- `pmacs-protocol/src/wire_grid.rs` holds the shared rules: checked area,
visible-cell bound, cell count, cursor bounds, glyph legality,
wide-continuation topology, the aggregate glyph budget, and the
attachment rejection. The 512 per-axis caps, metadata, selection spans,
and the at_bottom/scroll_offset coupling stay terminal-only.
- The attachment rejection is deliberately shared despite its
terminal-side wording: panels render no attachments either, so sharing
it fails closed for both.
Both byte pins were falsified by revert: moving `PanelFrame` ahead of
`InitialTargetResult` shifts it 27 -> 28 and fails; moving the three
events ahead of `TerminalPointer` shifts it 12 -> 15 and fails.
The factoring changed no terminal acceptance — all 17 terminal tests pass
unchanged. It did surface a pre-existing coverage gap: those tests pin
the row cap but never the column cap, so widening `max_cols` to u32::MAX
left them green. `a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not`
now covers that direction.
The daemon projection, the epoch state machine, and the GPU band are
later slices of this stage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR