Stage 2 of `docs/macos-ci-signal-integrity-framing.md` revision 3
(acceptance 6–9). Two test races, both the same shape: the thing waited
for was weaker than the thing asserted, so the wait could return inside
the window the assertion needs closed.
R4 — `wait_for_file` returned as soon as `fs::read` succeeded, which
succeeds on a ZERO-BYTE FILE. The probe publishes with
`open(path,'wb').write(b'1')` and `open()` creates the file before
`write()` fills it, so the helper handed `[]` to a caller asserting
`== b"1"`. It now takes the expected bytes and waits while the file
holds a STRICT PREFIX of them — the states a write in flight can be
observed in — returning anything else immediately so the caller's
`assert_eq!` stays the discriminating assertion rather than becoming a
timeout inside a helper that does not know what was expected.
All four callers pass their expectation. `wait_for_published_file`, one
function away in the same suite, gated the real-TUI smoke's
`assert_eq!(…, b"1")` on the identical predicate and is fixed with it:
leaving it would have let R4 recur under a different selector, which the
registry would then have had to judge a new incident.
R2 — the USR1 fixture waited on `ProcessEventKind::Started`, emitted at
SPAWN, not when `/bin/sh` has parsed `trap '' USR1`. SIGUSR1's default
disposition is terminate, so a signal inside that window kills the
child. The child now publishes a marker AFTER the trap and the test
waits for that marker's CONTENT (the same zero-byte trap applies to a
shell's `>` redirection). `exec` replaces the forked `sleep`, so the
group holds exactly one process and the ignored disposition survives by
POSIX rather than by the shell's fork-suppression optimization — an
unstated dependency the old fixture had, since these signals are
group-directed and a forked `sleep` is an untrapped group member.
Four witnesses, each verified by REVERTING the fix and observing the
failure rather than by reasoning about it:
- `wait_for_file_does_not_return_a_zero_byte_readiness_file` fails
`left: []`, `right: [49]` — R4's two required fragments, verbatim;
- `wait_for_file_does_not_return_a_partial_write` fails on the torn read
a length check alone would admit;
- `wait_for_file_returns_divergent_content_rather_than_timing_out` fails
against an over-strict helper that waits for an exact match;
- `usr1_readiness_waits_for_the_trap_not_for_the_spawn` fails
`left: Some("SIGUSR1")`, `right: Some("SIGTERM")` with the readiness
wait removed. Its fixture sleeps before `trap` so the pre-trap window
is deliberate rather than load-dependent, and it proves survival by
the child's EXIT DISPOSITION rather than by an absence observed within
a window.
R1 is NOT touched — referred to the async-runtime lane (Q#MCI3), because
widening its budget would make it pass and measure nothing more. R3 is
NOT touched and remains UNRESOLVED, owned by the process-signal /
reap-ledger lanes.
`docs/ci-red-signatures.md` moves R2 and R4 to a "Retired rows" section
with their dispositions and adds the rule the file needed and lacked: a
red matching a retired row is a RECURRENCE that puts the retirement in
question, never a known flake. `docs/active-work.md` carries this lane
from its first commit rather than after review asks for it.
Repetition sets, not single runs: the two `--lib` process tests 15/15,
the whole `vterm_stage2_acceptance` suite 15/15 at default parallelism.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage 3 review round 2. `vterm_stage3_acceptance`'s three terminal
fixtures opened with no `display`, so the flip placed them in a 12-row
panel — and this suite measures RENDERED FRAMES and CHILD PTY GEOMETRY.
It was measuring the geometry the flip had changed underneath it.
I nearly recorded this as a flake, and the reasoning that stopped me is
worth keeping. Two CI runs on the same commit failed DIFFERENT suites:
the first `vterm_stage2`'s real-TUI smoke, the rerun `vterm_stage3`'s
a37 plus two GPU terminal tests. Different failures across runs is the
load-sensitivity signature, both suites are on the documented flake
surface, and the ledger says a red a37 is "ambiguous by construction".
Every indicator pointed at noise.
But the tests it kept landing on were GPU TERMINAL tests, and terminal
placement is exactly what this PR changed. Checking rather than
concluding: all three `terminal.open` fixtures omitted `display`. So the
flip did reach them. It did not BREAK them — they pass locally, and they
passed in one CI run each — it made them MARGINAL, by shrinking the
window whose rendered output they assert against. Marginal under
lavapipe on the heaviest job in the workflow reads exactly like a flake
until you ask which tests, and why those.
The distinction that matters: "my change made this fragile" is a
different finding from "this was always flaky", and only one of them is
mine to fix.
These take the explicit opt-out for the same reason `vterm_stage2`'s
smoke already did — their subject is rendering, geometry and input
round-trip over a full document window, not placement, which the panel
suites cover. The reason is stated at each fixture rather than once at
the top, because each asserts a different property of the geometry.
Verified: vterm_stage3 9/9 with a37 taking 4.33s rather than the 0.17s
that means it never ran; vterm_stage2 6/6; and the full crdt sweep at
3718 passed / 0 failed against a measured 3715/0 baseline, the +3 being
this PR's new pins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
P1 — compile_mode_crdt_acceptance was never revised. Its three
`compile.run` fixtures still omitted `display`, so the flip placed
output in the panel with `select = false`, the source buffer stayed
active, and `adopt_next_buffer` timed out waiting for a snapshot that
only arrives via the ACTIVE-BUFFER-FOLLOW path. Its subject is CRDT
convergence of a generated buffer, not placement, so it takes the
explicit opt-out — and the reason is recorded at `adopt_next_buffer`,
the helper that actually depends on it, rather than at each call site.
WHY MY OWN VERIFICATION MISSED IT, which matters more than the fix: the
Stage 3 census and every sweep I ran used `--features luajit` WITHOUT
`crdt`, so no crdt-gated suite was ever exercised. The census was
therefore blind to an entire configuration by construction, in exactly
the way #209 exists to prevent. The CI crdt job — added by that lane —
is what caught this, three days after it landed.
Baselined rather than assumed. A worktree at the branch base 21de0b2
sweeps 3715 passed / 0 failed under `luajit,crdt` with
PMACS_REQUIRE_GPU=1; the branch with this fix sweeps 3715 / 0. Identical.
An earlier branch sweep, taken before this fix, reported SEVENTY
failures across THIRTEEN suites. Twelve of those suites — m5_5,
gpu_invocation, gpu_initial_target, m10_11, vterm_stage3 and the rest —
are daemon, socket or GPU suites, and all of them recovered by fixing
three compile tests. The likeliest reading is that the failing
compile-crdt runs leaked daemon or PTY processes that poisoned every
subsequent socket-based suite; what is ESTABLISHED is narrower and
still useful: baseline green, branch green with the fix, and the
collateral confined to process-spawning suites. Count failures, not
causes — the same lesson this stage already learned once, at a
different layer.
Also of note: CI reported only 3 failures because `cargo test` halts
after a failing binary. The workflow does not pass `--no-fail-fast`, so
CI under-reports a multi-suite break exactly as my first census did.
P2 — builtin/runtime/compile.lua's recompile comment still said `_last`
stores only cmdline/cwd. This PR deliberately stores `display` too, so
an explicit opt-out survives replay. Corrected, with the
`display_omitted` arm's remaining purpose stated rather than implied.
P2 — the framing's §7 step 2 still called the resolver extraction
"provably behaviour-preserving", contradicting §1.6b's own record of the
intentional non-string normalization. It is DEFAULT-PRESERVING WITH ONE
INTENTIONAL NORMALIZATION, and now says so where a reader following the
branch plan will hit it.
Verified: fmt, diff-check, compile_mode_crdt 3/3, compile_mode 73/73,
and the full crdt sweep at 3715/0 against a measured baseline of 3715/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage 3 step 5, criterion 5 — the one the framing named as most likely to
be quietly wrong.
`acc14` already proves capability fallback for an EXPLICIT `request.side`
at the core level, and it is not this case. Stage 3 resolves the default
into a PANEL REQUEST inside the adopter, so a pre-panel semantic frontend
now has to degrade a request the caller never wrote. Nothing in
`listview.open { name, rows }` says "panel", yet the request reaching the
core does — which is exactly why this is invisible from the adopter's
side and needs its own pin rather than an inference from acc14.
Asserts the three things that must survive the degradation: no side
window, no side parameters on the document window, and NO QUIT ACTION
left behind. That last one is the subtle half — a quit action stranded on
a document window would make a later `q` try to restore a presentation
that never happened.
Bite-verified rather than assumed: flipping the fixture's frontend to
`panel_capable = true` fails it on "a pre-panel frontend gets no side
window from the omitted default", so the test is measuring the
capability and not merely the absence of a panel it never asked for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
`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>
CI caught this on all four test legs. The forwarder body called
`pmacs.command.invoke_interactive`, which raises when the alias is
reached through `pmacs.command.invoke` — and
`tests/config_registry_acceptance.rs` does exactly that, three times.
The acceptance pin passed throughout because it drives the M-x path,
which is the path the framing spent three review rounds getting right.
Being right about one entry point is not the same as covering the
command, and a rename touches every caller of the old name regardless
of how it is reached.
Plain `invoke` is also the correct semantics rather than merely the
working one: the interactive-command boundary is rotated once, by
whatever entry point the user actually used, for the name they actually
typed. Rotating again on the inner call would record a second boundary
for a command the user never invoked.
Adds `d8c`, which invokes both forwarders programmatically. Bitten by
restoring `invoke_interactive`: the new pin fails alongside the three
config-registry tests that found it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
The isolation lane (#206) landed with an adoption ratchet, and it
caught this branch's brand-new suite on the first run after the merge:
these suites construct an editor through the ambient entry points,
so they read the developer's real init.lua and write into their real
data root: ["discovery_acceptance.rs (1 site(s))"]
That is the ratchet working as designed against code written by someone
who was not looking at the isolation lane while writing it — which is
the case it exists for.
`discovery_acceptance` is therefore MIGRATED, not allowlisted: it
includes `common/iso.rs` and constructs through
`EditorState::new_with_roots(&iso::roots())`. Allowlisting would have
put a fresh ambient site into the census the same day the census was
built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Review round 1: `isolated_construction_is_init_complete` asserted its
paired half — that the *ambient* constructor is unchanged — with an
ambient `EditorState::new()` in an ordinary parent test. That reads the
developer's real `init.lua` and materializes packages into their real
data root: the exposure this suite exists to remove, committed by the
suite itself.
The claim is worth keeping, so it moves rather than dies. It now lives in
the re-exec'd positive control, which runs only as a child under a
hostile-by-construction environment. That is the one place an ambient
constructor is safe, and so it is where every ambient claim this suite
makes belongs.
**The ratchet did not catch this, and that is the more important half.**
`ambient_isolation_acceptance.rs` was on the allowlist for its positive
control, and a bare file-level exemption licenses the named file to grow
new ambient sites forever — which is exactly what happened. So every
exemption now carries its **exact permitted site count**, and a file with
more sites than it was reviewed with fails even while allowlisted. A
count that drops fails too, so the allowlist stays a census rather than
drifting into a ceiling nobody rechecks.
The count immediately earned itself: it rejected the number written from
memory for `journey_acceptance` (47) and reported the real one (26 — 19
`new()` + 7 `open(`, after the scanner drops two assertion-message
mentions and the assembled `concat!` needle).
Verified in both directions: restoring the removed ambient site fails the
ratchet with `2 site(s), allowlist says 1`; and with the ambient half
gone, both init-complete pins still fail under the `if roots.is_ambient()`
mutation, so neither has become a test that passes for the wrong reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
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
The mechanical half, riding on the census in the previous commit.
* 342 in-process construction sites in 65 files now take
`new_with_roots` / `open_with_roots` with `iso::roots()`. The isolated
base is a pure function of `CARGO_TARGET_TMPDIR` — no counter, no
`OnceLock` — so two copies of the module in one binary agree instead of
racing, and the tree lives somewhere `cargo clean` owns rather than
leaking into `/tmp` once per run. It is shared deliberately:
materialization is content-gated and idempotent, so a per-test
directory would repeat it ~330 times per run for a byte-identical
result.
* `journey_acceptance` keeps the ambient `EditorState::open`, because
proving the production entry point has a caller is the whole of what
that ratchet is for. Rev 2's "isolated by the environment its binary is
launched with" was not a mechanism — cargo launches each test binary
with the caller's environment, and a binary cannot re-point its own
roots before its tests run. Each test is now a thin parent that
re-execs this binary for its own name with controlled roots, and the
child runs the body against production's call. Two pins guard it: the
child asserts all four roots resolve inside the controlled base, and
the suite asserts against its own source that it has not quietly taken
the seam. The parent also asserts the child ran `1 passed` — a stale
`--exact` filter would otherwise hollow the whole thing out silently.
* The shared spawners take all five storage variables.
`spawn_daemon_process_with_env` set `HOME` and `XDG_CONFIG_HOME` only;
`HOME` is a FALLBACK, so it isolates a root only while the matching
`XDG_*` is unset — the harness's apparent adequacy was a property of
one developer's environment. The PTY spawner backfills whichever of the
five its caller did not pin. The 10 direct `Command::new` daemon and
attach spawns get the same treatment.
Three suites had `mod common;` behind `#[cfg(feature = "crdt")]`;
`common::iso` is needed in every build, so those are ungated. Files that
already pull in `common` reach `iso` through a `use` rather than a second
`#[path]` declaration — loading one file as two modules is
`clippy::duplicate_mod`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
The census first, because it decides how large the mechanical edit is
(framing §7). Every occurrence was listed with its enclosing context and
read; a grep for the bare name over-counts, which is how revision 1
reported 18 by grepping `Editor::new` — a pattern that does not match the
real constructor.
in-process 342 calls in 66 of 97 files
(330 of 334 `EditorState::new()` occurrences; 4 are prose)
(12 of 14 `EditorState::open(` occurrences; 2 are strings)
spawned 14 real `pmacs` spawns in 8 files
(of 36 `CARGO_BIN_EXE_pmacs` hits, 18 are the fake-LSP and
fake-MCP siblings and 4 are path derivations for
`pmacs-gpu`, not spawns)
mixed 5 files are both, so sites — not files — are the unit
The full census, with per-site attribution, is the module doc of
`tests/ambient_isolation_acceptance.rs`.
Four things it pins:
* Isolated construction still finishes initialization, asserted twice —
the flag, and the behaviour it gates (`pmacs.attach` must refuse).
Falsified by wrapping the config block in `if roots.is_ambient()`;
`m8_2_acceptance` does NOT catch that, because reopening an already-open
init phase is a no-op.
* The writes land in the redirected data root — content produced, not an
invariant preserved. A "the real root did not change" check would pass
vacuously wherever it already holds identical bytes, since
`write_if_changed` is content-gated.
* Bet 3, in two children with opposite jobs. The positive control proves
the hostile environment IS hostile (an ambient editor loads its
`init.lua` and writes its data root); without it the isolation half
asserts nothing. The isolated child then stays green under the same
environment and leaves its hostile root byte-identical.
* A durable adoption ratchet, not a one-time census: a source scan that
fails when a new ambient constructor appears outside a named allowlist,
plus a check that no allowlist entry has gone dead. Its scanner strips
comments, strings and raw strings, and that stripping has its own pin —
the corpus contains all three shapes, and a grep-shaped answer already
cost this lane a review round.
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
The journey suite conflicted additively — step 4 from this lane, step 6
from #204 — and both are kept: 44 pins now cover steps 2, 3, 4, 5, 6
and 9.
Per §25 the audited claims this stage falsifies are updated on the
landing PR rather than deferred: the scorecard's row 18 and §18's
ground truth both read "Missing" / "missing entirely", and a welcome
buffer plus a reachable cheat sheet makes both false. They move to
Partial. §2's step-4 row stays Partial, because `C-h` still deletes a
word and there is no tutorial.
§18's ground truth now records WHY `C-h` stays as it is, so the
help-prefix question reaches the discovery arc as a stated trade rather
than an oversight: non-kitty terminals cannot disambiguate
Ctrl+Backspace from Ctrl+H, so rebinding it would break Ctrl+Backspace
on every legacy terminal.
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
Stage 1b-1 landed as #203, so the four places it deliberately left
saying "in flight" are flipped here rather than in a standalone docs
PR: this branch already touches all three files, and a separate PR
would re-conflict on every merge.
- `COHERENCE.md` §2's step-9 row: Partial -> **Works**.
- §2's keybinding-inversion paragraph: all three examples answered. The
quote itself is deliberately unchanged — it names a bias, and three
fixes do not retire a bias.
- §20 Priority 1 and the arc list: 1b-1 landed, 1b-2 in flight, 1b-3
remaining.
- `docs/agent-handoff.md` §1: IMPLEMENTED -> LANDED.
Conflicts were additive on both sides and are resolved keeping both:
the journey suite carries step 9 and step 6 (34 pins), and §24 keeps
both drift entries — the `ProjectKind::Cargo` naming error and §1.2's
wrong frequency note.
The two journey lanes are unified into one arc lane rather than one
being deleted. Rule 4 removes a lane when its ARC is done, and the
journey arc is not: 1b-2 is in flight and 1b-3 is unframed. Stage 1a
and 1b-1 are summarized there with their facts in the handoff, which is
rule 4's precondition satisfied rather than deferred.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Implements `docs/journey-stage1b2-lsp-guidance-framing.md` (approved at
revision 4, after three review rounds). Lua, tests and docs; no Rust
change and no protocol change.
`COHERENCE.md` §1.2's canonical silence: a preconfigured server that is
not installed failed with no status message, no record and no modeline
marker, while tree-sitter highlighting kept working and masked it. Now
the status line names the command, the language and the errno; the
modeline reads `LSP:!` instead of nothing; and `M-x lsp.status` renders
a durable `*lsp*` panel.
Half of this was already built. `status_buffer_text()` and
`last_error()` have existed since M4.8, exposed to Lua and tested, with
no production caller and no buffer to render into — several doc
comments already referred to "the `*lsp*` buffer" as though it existed.
The reporting shape was likewise already adopted twice inside
`lsp.lua`; the canonical case was silent because nobody had converted
it.
Three tables with three lifetimes, because one cannot do the job:
`reported` is never cleared and includes the command, so repointing at
another missing executable reports again; `failures` is cleared by a
successful spawn so the panel goes quiet on recovery; and a
buffer-keyed projection feeds the modeline, because that provider runs
for every window on every paint and deriving an affinity key inside it
would invoke root resolvers during painting.
The memo is on the report, not the failure: the spawn is still
attempted on every file open, so installing the binary mid-session
recovers with nothing to invalidate.
Adds `tests/lsp_spawn_guidance_acceptance.rs` (16 pins) and a step-6
row to the journey ratchet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Review round 1 on #203, two blocking findings, both accepted.
The compile-directory assertions used the suite's lexical `canon()`,
but `pmacs.project.detect` canonicalizes before walking
(`canonicalize_or_passthrough`, `src/project.rs:509-511`), so the
compile cwd is filesystem-canonical. On macOS `/var` is a symlink to
`/private/var` and the two spellings disagree — both macOS legs failed
while Ubuntu, where `/tmp` is not a symlink, stayed green.
Fixed with a `detected_root()` expectation, and pinned by a fixture
that launches through an explicit **symlink** so lexical and canonical
paths disagree on every platform. That matters more than the fix: the
original bite ran only on Linux, where nothing could make the two
differ, so no amount of local mutation testing would have caught this.
The new pin is the only one that goes red when the lexical expectation
is restored.
Second: `COHERENCE.md` §2's step-9 row was flipped to **Works** and the
handoff said Stage 1b-1 was **LANDED**, while this PR is open. §25 is
explicit that grades change only with landed evidence, never
aspirationally. Both now describe the real state — the row stays
Partial and names #203 as the open PR that closes it, §20 and the arc
list say "in flight", and the handoff says IMPLEMENTED with the PR
number.
The flip is not dropped, it is owned: the active-work lane records the
four places to change on merge, because an unowned doc flip is exactly
how this ledger's drift starts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
`-D warnings` with pedantic rejects an `if`-then-`panic!`. Same
semantics: the arming variable only makes a missing binary fatal, it
never decides whether the pin runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Implements `docs/journey-stage1b1-compile-defaults-framing.md`
(approved at revision 2). Lua, tests and docs; no Rust change and no
protocol change.
`C-c c` now runs `compile.run`, and the first prompt is prefilled from
the detected project kind through `pmacs.compile.defaults` — seeded
`rust = "cargo build"` and extensible from `init.lua`. `_last` still
wins, so a session that has compiled keeps its own command.
The prompt CAPTURES its directory rather than re-resolving it. Sharing
one resolver between the prompt and the run is necessary and not
sufficient: `pmacs.minibuffer.read` is asynchronous and nothing freezes
the active window while a prompt is open, so two calls to the same
resolver at two different moments are still two different answers — the
user could be offered `cargo build` for A and handed a run in B by
clicking away mid-prompt. This is Journey Stage 1a's `commit_to`
discipline on a smaller seam.
`pmacs.compile.defaults` is public and assignable, so the lookup is
guarded: a throwing `__index`, a non-string entry and a non-table
replacement all degrade to the pre-stage empty prompt and never
prevent compiling.
Only `rust` ships seeded. Rust has one answer; npm/yarn/pnpm,
make/cmake, and `go build` versus `go test` do not, and a wrong prefill
costs more than an empty one.
Adds eight step-9 rows to the journey ratchet and five module pins to
the compile suite. Corrects `COHERENCE.md`, which named a
`ProjectKind::Cargo` that does not exist — the variant is `Rust`, line
77 is its doc comment, and Lua only ever sees the tag string "rust".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
`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.
`version_mismatch_clean_disconnect` asserted that the `VersionMismatch`
`server` field equals `ADVERTISED_PROTOCOL_VERSION`. Until this stage
that constant and `PROTOCOL_VERSION` were both 20, so the assertion could
not distinguish them and happened to pin the wrong one. Stage 2B-3 splits
them — the advertised value is a compatibility floor, `PROTOCOL_VERSION`
is the ceiling a frontend may counter-offer up to — and the daemon
correctly reports the ceiling, so the stale assertion failed on all four
CI Test legs.
The production behaviour is right and is unchanged here. Only the test
moves, and it now pins the divergence in both directions: the `Hello`
assertion above holds the advertised floor, a new `assert_ne!` holds the
fact that the reported version is deliberately not that floor.
That second assertion is why this is not a one-character edit. Stage
2B-3's own pin for this rule, `an_unsupported_offer_is_refused_by_name`,
is `#[cfg(feature = "crdt")]` and CI never enables `crdt` — so it is dark,
and `m5_5_acceptance` is the only live guard CI runs on this behaviour.
Bite: reverting `src/daemon.rs:757` to `ADVERTISED_PROTOCOL_VERSION`
fails the test with `left: 20, right: 21` and the named message; restored,
it passes. Verified against the whole suite under an isolated
`XDG_CONFIG_HOME` — 3303 tests, 100 binaries, zero failures.
`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 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
Bite-verifying the round-1 pins caught one of them passing with the bug
restored. `contains("only.txt")` was satisfied by the status message's
own `deleted only.txt:` prefix — the deleted path's basename — so
stripping the `buffer "…"` attribution changed nothing the assertion
could see.
Both halves now assert the buffer's OWN name, which for a path-backed
buffer is the full path and which only the attribution can produce.
Dropping either name — the refusal reason's or the kept-modified list's
— now fails.
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
Three float comparisons became explicit epsilon checks, one const-valued
assertion moved into a const block, and the crdt-only half of the new
acceptance suite is now gated import-by-import.
That last one is the interesting part: the negotiation-rule tests are pure
and run in BOTH configurations, while everything needing a real daemon
needs the crdt feature — a semantic session is necessarily a text replica,
so a non-CRDT build cannot host one at all. Splitting the imports along
that line is what keeps the default clippy configuration clean while
leaving the version-ladder assertions where CI can actually reach them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Handoff section 1 gains the 2B-3 bullet and the protocol anchor moves to
v21 with the rule that matters stated once: advertise the baseline,
negotiate up from the frontend's AttachRequest, and reserve moving the
advertised version for a change that cannot be expressed additively at
all. 2B-1's forward-looking constraint is marked discharged rather than
deleted, because its acceptance still passes unchanged and that is the
evidence.
COHERENCE section 14 grades the bottom/side panel primitive as complete on
BOTH frontends rather than 'Stage 2 pending its own framing', section 20
P5 follows it, and the section-19 protocol bullet records that the v21
family is live in production without an incompatible handshake change.
The active-work lane is rewritten to the shipped slice, including the
rejected activation alternatives and why the server-first shape forces
each one out, and the one-way compatibility window it leaves open.
The 2B-2 acceptance suite's header said production keeps panel_capable
false for every semantic session. That is no longer true, and its
assertions did not change — which is the point, so the header now says so
rather than being quietly left stale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
23 falsifying mutations, each executed. Three of the first-pass assertions
were VACUOUS and the mutation runs are what found them:
* The contrast assertion compared status_band_top before and after
installing a panel — a FIXED POINT. The blanket rewrite the framing
exists to prevent (subtract the band from the status boundary too)
moved both readings together and passed. It is now anchored to an
independent formula: the physical window bottom minus the band height.
* The criterion-46 pixel test only checked that no pixel moved above the
band and none below it. Installing a panel reshapes the document to the
smaller height, and THAT produced the whole diff — so the test passed
with the band painting nothing at all. It now counts differing pixels
in the divider row and the band's cell rows directly: content produced,
not an invariant preserved.
* A2B-3's fixture compared two ASCII documents, which in a monospace
family have identical glyph advances — so it could not tell the stable
probe from the document-glyph fallback. It now separates the two
derivations explicitly and asserts they produce different column counts
in the fixture, so the claim about which one the declaration uses is
discriminating.
And one about the CODE, not the tests: 'a v20 semantic session receives no
panel frame' is defence in depth, not the placement gate. The producer's
peer flag and the write-loop filter both suppress PanelFrame below the
panel version independently of panel_capable, so that claim passed with
the capability gate removed entirely. The load-bearing claim is placement:
the adopter's buffer must land in the pre-panel session's own DOCUMENT
window, because a side window it cannot render is simply invisible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
`rd9` and `rd14` pinned #190's deliberate restraint on the
`apply_resource_op` delete arm: descendants stay orphaned, and only the
first of two duplicate path-bound buffers is reconciled. Both doc
comments gave the same reason — widening would have routed N buffers
through `remove_buffer_and_fire`, which is phase 2 without phase 1, so a
tree delete would have left up to N windows on removed ids.
`EditorCore::reconcile_delete` composes both phases, so that constraint
is discharged and the old assertions are no longer merely obsolete: an
orphaned buffer whose next `C-x C-s` recreates a file the user deleted
is the defect. Each row now asserts the new contract in BOTH directions
— the buffer is reconciled away, AND no window holds a removed id — so
neither an exact-path/first-match regression nor a widening that skips
phase 1 can pass. Each direction is bite-verified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Placed by what each assertion needs: the band's pixel geometry, the epoch
latch, the probe-derived columns, and the three-boundary contrast
assertion live in pmacs-gpu's own tests because they need a real State and
a real surface; the handshake, the negotiation, and the capability flip
live in bottom_panel_stage2b_gpu_acceptance because they need a real
daemon.
Every acceptance runs both directions in one fixture. The activation test
uses ONE daemon for all three halves — a shipped v20 client reaching its
initial grid, a v21 counter-offer receiving a Present band, and a v20
semantic session that is never sent a panel frame and keeps a live
document window. Two daemons could each pass their own half while the same
build was incapable of serving both, which is the only property that
matters.
Two real defects the new tests caught in my own implementation:
* edge_scroll_direction has no upper bound, so moving its boundary was
necessary but not sufficient — a pixel inside the band still read as
'further down the document' and armed the document's auto-scroll. That
is the exact named symptom of leaving that consumer on the old bottom.
The falsification keeps both answers: the probe pixel is inside the
unmoved boundary's own edge strip, so the two genuinely differ, and a
third assertion proves the feature is not simply switched off.
* apply_panel_payload ignored the exhaustion latch, so a latched session
kept storing frames and reporting 'changed'. That left presented() as
the only thing between a disowned declaration and a painted band, and
spent a reshape on every arriving frame for the rest of the session.
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
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
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.
Keep both active-work lanes while taking the silent-skip arming and
generated-buffer framing changes from current main. The resource-op lane
retains its round-2 fixes and updates its recorded merge-base.
Normalize batch dependency paths through the registry's lexical
canonical form so equivalent URI spellings do not revive the
initial-state preflight bug.
Separate execution-started state from the count of completed plan
items. Preflight failures retain the no-mutation guarantee, while
runtime failures conservatively acknowledge that the failing item may
itself have changed a buffer or the filesystem.
Add real-server-pump acceptance for dot-path dependency aliases,
partial text edits within one item, and resource-operation side
effects, and record the review-round corrections in the framing.
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.
Review round 2 found no new defects; this is the one durable item it
asked to be carried further than a commit message.
`cmd | tail -2` returns TAIL's exit status, not `cmd`'s, in fish and
bash alike. So a gate chain of `cargo test ... | tail -2 && ... && echo
"ALL GATES CLEAN"` prints the clean line even when a suite failed, and
that is what happened while gating this lane: a `pmacs-gpu` failure was
summarized as clean. The point worth keeping is that this is not
carelessness a closer read would catch --- the verdict is structurally
absent from the summary the PR then cites. §5 now says to check
`$pipestatus[1]`, or better to redirect each gate to a file and read it
afterwards, which also preserves the full log that section already asks
for. Filed beside the skip-reports-`ok` lesson, which is the same
family: the thing that summarizes a gate must not be able to lose the
gate's verdict.
Also fixes the doc-comment splice in `tests/support/mod.rs`, where the
why-two-directories paragraph landed mid-sentence and left the
include-mechanics explanation stranded inside it. Cosmetic, and review
called it not worth a round on its own --- folded in here because the
file was being touched anyway.
P1 --- the workflow contradicted this lane's own claim. `components:
rust-analyzer` rode the shared `dtolnay/rust-toolchain` step, which
matrixes over ubuntu AND macos, so the binary would have been present
on both. PRESENCE, not PMACS_REQUIRE_LSP, is what decides whether a
gated test body runs --- the unset variables on macOS only meant
absence would be tolerated there, not that the tests stay skipped. Two
rust-analyzer tests would therefore have executed on macOS for the
first time ever, on the legs that are simultaneously the CI critical
path and the documented flake surface, one of them doing real indexing,
none of it covered by the Linux-only local runs behind this lane. Moved
into the Linux-gated step so the text and the workflow agree.
P2 --- the ledger promised a base and pasted nothing. Worse than the
review knew: a script was run to substitute it, reported success,
matched no text, and the result was never re-read. The claim shipped on
the strength of a tool's exit status. The entry now carries the pasted
base and a recovery command, and records the lesson in the terms this
ledger keeps relearning --- asserting a measurement is not making one,
and a tool reporting success is not the measurement either.
P3 --- tool versions pinned (gopls v0.16.2,
vscode-langservers-extracted 4.10.0, yaml-language-server 1.15.0), so
CI no longer drifts with upstream publishes and a break has a commit
here to bisect against. `tests/support/` now states why it exists
beside `tests/common/`: the latter re-exports daemon and PTY
machinery, and pulling that into three binaries that spawn neither to
reach a six-line environment check is the wrong trade. Recorded as a
cost, with the rule that a third such directory means consolidating
rather than continuing.
Also recorded, because §1.2 is NOT fully closed by this lane: the
guards arm the ENTRY skip only, and m4_acceptance's mid-test
"workspace likely still indexing; skipping" survives --- so even armed,
that test's one assertion can still vanish under load, which is exactly
when a regression would show. Mid-test skips want their own pass. And
the follow-up this lane creates: removing the documentation lane
removes the only pointer to githubsucks/handoff-2026-07-20, so that
branch needs deleting after merge or nothing will ever mention it
again.
P4 --- the consolidated lua skip message names PMACS_TEST_LUAJIT again;
the `or_else(locate_shell("luajit"))` path still honours it and the
escape hatch had become undiscoverable. Double blank line before the
parked lane collapsed.
One gate note worth carrying rather than burying. A `PMACS_REQUIRE_GPU=1
cargo test -p pmacs-gpu` run failed once during this round, naming the
`--bin pmacs-gpu` target, and my gate chain still printed a clean
summary because `cmd | tail -2` in an `&&` chain returns TAIL's exit
status, not cargo's. Four subsequent runs pass 202/202 and the failure
has not reproduced, so it is recorded as observed-and-unreproduced
rather than explained. The masking is the durable part: a gate summary
assembled through a pipe can report success over a failure.
Gates re-run after the fix: fmt; clippy -D warnings; --lib 1863; --lib
--features crdt 2048; m4_acceptance 121; m6_5 11; m6_8 8; required GPU
202 (x4); git diff --check clean.
Lane 2 of the testing arc (`TEST_IMPROVEMENT.md` §1.2, §5.4).
The shape being fixed reports GREEN when the tool is missing:
let Ok(_) = which_binary("gopls") else {
eprintln!("gopls not on PATH; skipping");
return;
};
CI installed none of these tools, so a block of real-language-server
and multi-shell tests had never once executed their bodies while
reporting success on every run. A suite that cannot distinguish
"passed" from "never ran" is worse than a missing suite, because it
reads as coverage in exactly the place someone would go looking for it.
The fix is this project's own pattern rather than a new one:
PMACS_REQUIRE_GPU already turns a missing adapter into a hard failure
for the headless render job. This adds PMACS_REQUIRE_LSP,
PMACS_REQUIRE_SHELLS and PMACS_REQUIRE_LUA, and the CI step that
installs the tools they promise. Per-tool variables rather than one
blanket flag, so a tool that must stay unarmed keeps that decision
visible at the call site instead of buried in a workflow file.
basedpyright is deliberately NOT installed and NOT armed. Its test has
no timeout and hangs forever; the root cause is the non-interruptible
reader-thread join in `RuntimeHandles::drop`, already a named deferral
in `src/process.rs`, and the `test` job has no `timeout-minutes`.
Arming it today would trade a vacuous green for a six-hour hang across
four legs. PMACS_REQUIRE_PYRIGHT exists and is never set, so the flip
is one line after the hang fix and the CI timeouts land.
A trap found while writing the workflow rather than after: the natural
Actions idiom
PMACS_REQUIRE_LSP: ${{ runner.os == 'Linux' && '1' || '' }}
sets the variable to the EMPTY STRING on every other platform, and
`var_os(..).is_some()` is true for `Some("")`. That would have armed
the guard on precisely the runners with none of the tools installed
and failed every one of them. The helper treats empty as unset, which
makes the common spelling safe instead of subtly wrong.
The helper is SHARED via `#[path = "support/mod.rs"]` rather than
copied into three test binaries. `m6_8_multi_repl_acceptance.rs`
carried a comment saying cross-test-binary sharing "would need a
fixture crate"; it does not, and a correct helper in one file beside a
degraded copy in another is this suite's most repeated defect.
Verified by execution in all three states, using a tool genuinely
absent from this machine (vscode-json-language-server): unset skips
green; armed fails hard, naming the CI step that should have installed
it; empty string skips green. On `main` the armed state cannot fail at
all, because no guard exists.
And the question none of this could answer until now --- whether the
tests pass when they actually run --- is answered: armed locally, 11
m6_5 and 8 m6_8 REPL tests are green, and all six real-LSP tests
(clangd x2, gopls x2, rust-analyzer x2) pass individually. The coverage
was real the whole time. It just never ran.
Linux only for now, deliberately: macOS needs the brew equivalents and
roughly doubles install cost on the slowest matrix leg. The variables
stay unset there, so those tests skip cleanly as before.
Also removes the documentation lane from the ledger. Its disposition
was left undecided pending confirmation that its branch carried
nothing unique; measured, `githubsucks/handoff-2026-07-20` is 1 ahead
and 365 behind, and its whole unique diff is four doc files at 42
insertions against 88 deletions --- merging it would REVERT current
documentation. The section asked whoever confirmed that to remove it.
Gates: fmt; clippy -D warnings; --lib 1863; --lib --features crdt
2048; m4_acceptance 121 (unarmed, per CLAUDE.md); m6_5 11; m6_8 8;
PMACS_REQUIRE_GPU=1 -p pmacs-gpu 202; git diff --check clean.
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.