Commit Graph

292 Commits

Author SHA1 Message Date
Levi Neuwirth 7a271f13f5 merge: integrate main @ 391d38a (PRs #197, #196, #191) into Stage 2B-3
`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.
2026-07-30 11:16:23 -04:00
Levi Neuwirth 80985ed6dc test(protocol): pin the version-mismatch divergence, not the old constant
`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.
2026-07-30 10:45:27 -04:00
Levi Neuwirth 12e2cff466 merge: integrate main @ c14f2de (PRs #197, #196) into the Stage 1 lane
`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`.
2026-07-30 10:13:37 -04:00
Levi Neuwirth 5fe7eb30b6 fix(bottom-panel): finish the panel port, not one omission at a time
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
2026-07-29 22:29:24 -04:00
Levi Neuwirth a131c880e2 test(stage2a): make acceptance 53's attribution assertion bite
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
2026-07-29 21:34:42 -04:00
Levi Neuwirth 44135fcd73 fix(stage2a): close review round 1 — four unreported failures
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
2026-07-29 21:28:16 -04:00
Levi Neuwirth e00cdd7ca6 fix(bottom-panel): satisfy the strict clippy gate in both configurations
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
2026-07-29 20:28:41 -04:00
Levi Neuwirth 8ee08d93e5 docs(bottom-panel): record Stage 2B-3 and close Stage 2
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
2026-07-29 19:12:36 -04:00
Levi Neuwirth 71cc31bfbb test(bottom-panel): close three vacuous assertions the bites exposed
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
2026-07-29 19:06:33 -04:00
Levi Neuwirth f6af3f7335 test(m4): re-pin two rows whose parked behaviour Stage 2a discharges
`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
2026-07-29 19:05:51 -04:00
Levi Neuwirth 5e30e97f17 style(stage2a): drop an unused mut the new delete row left behind
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 18:54:22 -04:00
Levi Neuwirth 57be2bacd1 test(bottom-panel): acceptance for the band, the activation, and the flip
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
2026-07-29 18:51:55 -04:00
Levi Neuwirth edfb52f29c test(stage2a): make three bites bite, and correct one framing claim
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
2026-07-29 18:51:41 -04:00
Levi Neuwirth 3c66370b8c test(stage2a): the reconciliation acceptance suite
`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
2026-07-29 18:40:38 -04:00
Levi Neuwirth 431d844322 feat(bottom-panel): split the GPU document bottom into three boundaries
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
2026-07-29 18:30:57 -04:00
Levi Neuwirth d03845e826 feat(bottom-panel): activate protocol v21 by frontend counter-offer
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
2026-07-29 17:42:08 -04:00
Levi Neuwirth 7350cfdd8a merge: integrate main @ e003b81 (PR #190) into the Stage 1 lane
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.
2026-07-29 15:12:13 -04:00
Levi Neuwirth ef5bd89e84 merge: integrate main @ b7bf2c6 after PR 190 review round 2
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.
2026-07-29 13:49:06 -04:00
Levi Neuwirth cb7fe818bd fix(lsp): close review gaps in workspace edit reporting
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.
2026-07-29 12:29:50 -04:00
Levi Neuwirth 5d92348054 fix(editor-core): normalize rewritten selections
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.
2026-07-29 12:26:08 -04:00
Levi Neuwirth 0b92d4705f docs: record the pipe-masking gate trap in the handoff
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.
2026-07-29 12:25:29 -04:00
Levi Neuwirth a81ff917d8 review round 1: stop installing rust-analyzer on macOS, and paste the base
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.
2026-07-29 12:20:28 -04:00
Levi Neuwirth 35b119700f test: arm the silent skips, so external-tool tests stop passing vacuously
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.
2026-07-29 11:59:08 -04:00
Levi Neuwirth 6b21e93e93 fix(lsp): close review round 1 on the resource-op delete guard
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.
2026-07-29 11:45:43 -04:00
Levi Neuwirth 4da4830f80 fix(editor-core): drop a selection anchor a rewrite invalidated
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
2026-07-29 11:27:17 -04:00
Levi Neuwirth e1b859fd4f feat(generated-buffers): dired and listview adopt the authorized write
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
2026-07-29 09:50:09 -04:00
Levi Neuwirth 1873be6141 feat(lsp): refuse a resource-op delete that would destroy unsaved work
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.
2026-07-29 09:47:35 -04:00
Levi Neuwirth bfaaf2bff6 Close PR 187 review round 2
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.
2026-07-28 21:30:50 -04:00
Levi Neuwirth 3ecb03d949 Close review round 1: five findings, plus one the sweep found
All five review findings reproduced with a failing test before any fix,
and every fix falsified by reverting it.

R1-1 — the wire-area clamp lived only in `panel_grid_size`, so the daemon
shipped an authoritative `Absent` while `panel_hidden` stayed false:
keys kept reaching the invisible window and a panel terminal kept its
controller. Q#BP2b calls hiding a DURABLE state transition and the
exhaustion arm had made it a per-frame effect. Fixed structurally rather
than pointwise: `presentable_panel_grid` is now the one derivation behind
both the renderer and `reconcile_panel_layout_core`, so the two cannot
drift apart again.

R1-2 — closing and reopening the same PERSISTENT buffer inside one
dispatcher burst left the shipped declaration intact while the window it
described was already dead, and same-buffer/same-size made the successor
indistinguishable by every other field. A presentation epoch only
identifies a presentation if something checks that the presentation it
names is still on screen, so `panel_declaration_matches` now takes the
live side window and buffer.

R1-3 — the semantic terminal-layout twin consulted only the full-document
declaration, which a panel terminal deliberately lacks, so the child kept
its opening geometry through the drain. `sync_semantic_panel_terminal_
layout` is the missing case; it resolves through `side_window_for` while
its sibling resolves through `primary_document_window`, so the two are
disjoint by construction and nothing is resized twice per tick.

R2-4 — `NoMessage` means publish nothing, not publish empty. Treating it
like `Invalidated` removed the band's provider text on a transient
buffer-follow mismatch. The band repaints its whole mode line every
frame, so "publish nothing" has to be a retained baseline; it is keyed by
window id so a replaced panel inherits nothing.

R2-5 — non-`Move` activation is Q#BP16's TERMINAL clause, because the
shared adapter claims the controller for wheel steps too. A document
panel keeps scroll-without-focus, matching `dispatch_mouse`.

The sweep for R1-1's and R1-3's shape found one more, and it is the same
bug as R1-1: a panel wider than the terminal subsystem's per-axis cap is
legal on the wire (Bet B5') but its content rect was refused by
`snapshot_for_view`, collapsing the projection to `None` — a per-frame
`Absent` with the durable state still saying visible, reachable with one
`FrontendCellGeometry` declaration. The band is legitimately that wide,
so the child is clamped to the columns a PTY can have and the remainder
paints as band background, exactly as a narrower snapshot already does.

One knowingly per-frame `Absent` remains and is recorded in the code
rather than fixed: presentation-epoch exhaustion, which takes 2^64
shipped presentation changes in one session and cannot be reached by any
frontend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-28 18:52:43 -04:00
Levi Neuwirth cbaeb1148f merge: integrate landed main (#174, #185) into this slice
Docs-only and one m4 test fix; no overlap with the daemon projection.
Integrating now so CI builds a real merge ref for the head SHA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-28 17:50:27 -04:00
Levi Neuwirth 37f1ca936b Keep the passive-panel fixture inside its buffer
`clippy::format_collect` rejected the generated body; the replacement
was shorter than the hard-coded caret offset, so the caret landed past
the end and the focused half stopped exercising the clamp. Derive the
offset from the body instead of restating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-28 17:34:40 -04:00
Levi Neuwirth 817b134ae8 Produce PanelFrame and gate the inbound panel events
Bottom-panel Stage 2B-2, second half: the producer, the presentation
epoch, and the three inbound event gates.

The producer lives beside the terminal pass in `semantic_render.rs` and
follows its shape: compare the complete payload first, validate only a
payload that differs, and store only what was actually shipped. The
presentation epoch is allocated from the side window and its buffer, so
a new side window, a replaced buffer, and every `Absent` -> `Present`
transition each take a fresh identity; `Absent` clears the identity,
which is what makes close/hide/reopen of the SAME persistent buffer
unaddressable by a stale `PanelPointer`. Allocation is checked and
exhaustion fails closed to `Absent` rather than wrapping into a live
identity. The `Absent` baseline is seeded rather than left empty: a
fresh session has no band, so the opening state is a fact the peer
already holds.

The band rides both render paths and does not wait for a declared byte
viewport: it is a separate surface, and gating it on the document
declaration would leave the first panel unpaintable. Its mode line takes
the side window's segments from the SAME provider invocation that serves
the document's wire segments.

Inbound, `peer_may_send_panel_events` checks four facts together — an
installed semantic projection, the negotiated version, the daemon's own
capability bit, and (via the transport source) that the payload's
claimed id is never consulted. `panel_event_epochs_are_current` then
runs Q#BP16 steps 2-4 as one predicate so no caller can check the
geometry epoch and forget the presentation epoch.

Two daemon gates moved from `panel_capable` to `!semantic_render`. Stage
1 could conflate them because panel capability implied grid; now that a
semantic view can be panel-capable, a capability-keyed gate would feed it
the permanent 24x80 attach placeholder that Q#BP15a forbids, and parent
acceptance 40 would fail through the attach line rather than through the
projection. Not a live defect — no production semantic session is
panel-capable yet — but it is the landmine Stage 2B-3 would have stepped
on.

`panel_capable` is unchanged for production negotiation and the
unsolicited `Hello` still advertises v20. Nothing here is reachable by a
user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-28 17:28:43 -04:00
Levi Neuwirth 302c21c688 Merge remote-tracking branch 'githubsucks/main' into fix-m4-sink-races 2026-07-28 16:31:13 -04:00
Levi Neuwirth 9c79ce13b2 Fix fixture-specific GPU probe completion
Let producer probes name the frame text they require while input probes
finish on their latched echo observation. Report and assert whether the
probe reached that evidence so the 20-second safety deadline cannot
masquerade as successful completion.

The CAT acceptance now finishes in 0.32 seconds instead of waiting out
the full deadline, while the VTERMROW producer still waits for its own
PTY breadcrumb.
2026-07-28 15:28:14 -04:00
Levi Neuwirth ab7c207904 Keep the v21 panel wire dark for v20 clients
Reserve the additive v21 panel schema without advertising it in the
server-first production handshake. Pin a real shipped-v20 client attach,
make the two aggregate-budget ratchets exactly one byte over, and update
the framing, coherence audit, handoff, and volatile lane record.
2026-07-28 14:08:17 -04:00
Levi Neuwirth c8895a85d3 Merge canonical main into bottom-panel Stage 2B-1
Integrate main through the Journey/GPU directory-target ratchet (#183).
The public managed-GPU probe additions compose with the v21 wire layer.

Resolve the volatile ledgers to the landed state: advance the canonical
anchor to 7fd646d, remove the completed ratchet lane, preserve the
approved Stage 2 revision-5 recovery facts, and mark the integrated
2B-1 tree as awaiting its from-start full gate rerun.
2026-07-28 11:38:19 -04:00
Levi Neuwirth dc2dc42627 test(journey): retain daemon cleanup while awaiting dired
Capture the managed daemon's lifecycle facts from the ready report before
waiting for the post-quiescence directory snapshot. If that regression
assertion times out, the acceptance harness can now terminate the daemon
instead of leaving it orphaned.
2026-07-28 11:02:48 -04:00
Levi Neuwirth 34b8f28cf2 test(journey): ratchet the public GPU directory path
Drive pmacs --gpu . through the root broker and real managed GPU
connector, keep the session alive through the asynchronous dired
replacement, and assert its canonical listing before daemon reuse.

Expose snapshot count and materialized text through the private
display-less acceptance probe so the public path is observable.
2026-07-28 10:27:22 -04:00
Levi Neuwirth ec4191fd8e test(journey): pin post-quiescence GPU dired surface
Consume the directory session's later replacement snapshot and assert
the canonical dired header plus a known listing entry before checking
daemon reuse.

Correct the bottom-panel revision-5 recovery branch and advance the
durable handoff to the Journey Stage 1a main anchor.
2026-07-28 09:48:48 -04:00
Levi Neuwirth 486ce16748 test(journey): ratchet directory GPU bootstrap success
Replace the stale directory-negative in the GPU initial-target suite
with an explicit snapshot-first readiness path, while retaining all
genuinely malformed and unloadable failure cases. Record the portable
side-quest and bottom-panel dependency state.
2026-07-27 22:53:36 -04:00
Levi Neuwirth b9123c2f6d test(protocol): advance touched-suite ratchets to v21
Make the statusline and Vterm Stage 3 acceptance suites track the
bottom-panel v21 bump, including the real daemon and headless GPU probe.
Record the full gate result and the unrelated stale directory-target
assertion reproduced on canonical main.
2026-07-27 22:39:15 -04:00
Levi Neuwirth d88d60eba6 Merge canonical main into bottom-panel Stage 2B-1
Integrate the Journey Stage 1a merge without rewriting the already
reviewed protocol branch. Record the approved three-way Stage 2B split,
advance the canonical recovery anchor, remove the landed Journey lane,
and put 2B-1 into its full-gating state.
2026-07-27 22:16:11 -04:00
Levi Neuwirth 8e31ca4646 Merge remote-tracking branch 'githubsucks/main' into journey-stage1a-directory-open 2026-07-26 18:33:44 -04:00
Levi Neuwirth 7741cf806a fix(journey): honor the captured window, not the selected one
Review round 1 of PR #182. One implementation gap and two stale claims.

**The scope pins the frontend; it does not pin the window.** Framing
§4.4 specified `display{ window = dest:window() }`, but dired's commit
still ended in `pmacs.window.switch_buffer`, which targets whatever
window the scoped frontend has selected. A split or panel that took
focus while `read_dir` was pending therefore received the listing, and
`prev` was captured from it too — with every preflight check passing,
because the captured window was still live and still held its captured
buffer. Both sites now read the captured window: `display` routes to it
with `select = true` (the later `seat_cursor` acts on the active
window), and the `prev` read asks it directly.

N4c pins both halves. The suite's existing routing pins all varied
*frontend* identity; none varied the selected window within one
frontend, which is exactly why 23 green pins missed this. Bite: dired's
`display` back to `switch_buffer` fails N4c alone; `prev` read from the
ambient window fails N4c alone.

Two stale documentation claims, both of which this PR was supposed to
have already fixed:

* **The §0 scorecard still graded §2 "Broken at entry"** while §2's own
  ground truth had been rewritten. The scorecard is a second copy of the
  same claim and §25's protocol covers both. §19's row and ground truth
  were stale the same way — this PR creates the first cross-subsystem
  suite, which §19 says should exist and grades as missing — and are
  corrected too.
* **P4 still read "leaves exactly one buffer"**, the exact claim rev 6
  corrected as false everywhere else in the framing. Restated to what it
  actually pins: the file is in the *active window*. The test was
  already written correctly; only the framing lied.

Framing rev 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:20:02 -04:00
Levi Neuwirth 9b364adc26 fix(panel): review round 1 — buffer_id, the transport ratchet, shared bounds
P1 — `PanelPointer` was missing the approved `buffer_id`. Q#BP16 gives it
and `panel_epoch` different jobs and neither subsumes the other:
`buffer_id` catches an A->B buffer replacement, `panel_epoch` catches
close/hide/reopen of the SAME persistent buffer, which a buffer id alone
cannot see. Added in the framing's field order, with a pin asserting each
field independently reaches the wire.

P1 — added parent criterion 39's transport-safety ratchet. It builds the
maximum legal panel payload, asserts the fixture actually spends the whole
aggregate glyph budget (otherwise the ratchet measures something smaller
than the worst case), asserts one byte more is rejected, and pins the
encoded `InstanceMessage::PanelFrame` below `MAX_FRAME_BYTES`. Shaped
`1 x MAX_PANEL_VISIBLE_CELLS` deliberately: no per-axis cap makes that a
legal panel geometry a terminal cannot express, so it is the worst case
the terminal's own ratchet never measured. Bitten by tripling the glyph
budget — 30,342,696 bytes against the 16 MiB cap.

P2 — the shared bounds were duplicated literals. `MAX_TERMINAL_GRAPHEME_BYTES`
now aliases `MAX_WIRE_GRID_GRAPHEME_BYTES`, so the terminal screen's
truncation (`src/terminal/screen.rs:697`, `:777`) and the validator cannot
drift. Two more had the same defect and are aliased too:
`MAX_TERMINAL_VISIBLE_CELLS` and `MAX_TERMINAL_FRAME_GLYPH_BYTES`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR
2026-07-26 17:31:04 -04:00
Levi Neuwirth d9d1c0b423 test(journey): pin the destination commit, and land the doc updates
Completes Journey Stage 1a: the `commit_to` acceptance suite (framing
§6 N4, N6, N6b, N6c, P1, P2, P3) plus the documentation updates
COHERENCE §25 requires the PR to carry.

Bite-testing the new pins found a real gap. Deleting the
`ScopedFrontend` arm from `acting_frontend` left N4 green, because
`ScopedFrontend::enter` also swaps `core.active_frontend` and the
ambient fallback then answers correctly on its own. The arm is
load-bearing in exactly one case — a commit reached from inside an
interactive command, where the origin sits between the override and the
ambient value — and nothing pinned it. N4b is added, driven through
`dispatch_key` because that is the only thing that establishes an
interactive origin, and the mutation now bites it.

Two smaller corrections found the same way:

* `commit_to`'s forged-destination message was unreachable. Typed as
  `AnyUserData`, mlua rejected a table during argument conversion, so a
  caller got "error converting Lua table to userdata" — true, but naming
  neither the rule nor the remedy. The parameter is now `mlua::Value`
  and the pointed message fires.
* P1 and P2 also fail on full revert, since `commit_to` does not exist
  on the pre-image, so §6.0's "legitimately green on the pre-image" does
  not describe them. They stay in the P list because their
  discriminating falsifier is the named mutation — a revert-only check
  cannot distinguish "validates" from "validates in time" — and each pin
  now says so at its own site rather than being silently mislabelled.

Bite results, each run against the whole suite:

  scope stops swapping `core.active_frontend`  -> N6a, P3 fail; nothing else
  preflight moved after the callback           -> P1, P2 fail; nothing else
  drop the `ScopedFrontend` arm                -> N4b fails; nothing else

Docs: COHERENCE §2 grade + step-3 verdict row, §20 Priority 1 and the
arc list; the GPU initial-target framing's Q#GT6 and acceptance 10,
whose directory case this stage deliberately supersedes; handoff §1;
the active-work ledger; framing rev 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:25:22 -04:00
Levi Neuwirth d1bff6ac30 fix(lean4): count fan-outs where a claim cannot skip the count
Round 11 put the nesting count in the expander, which is optional. A
consumer at a lower priority can CLAIM and stop the chain before the
expander runs, while that fan-out's deferred-expansion subscriber still
runs — so the nested pass went uncounted, looked like the outermost
one, expanded early, and outer pairing resumed with a record the
replace had invalidated. `\alp(` gave `α(` again.

The count now comes from a no-op consumer registered at the minimum
priority, which runs first in every chain invocation that reaches any
consumer at all. Its guarantee is exactly the ordering contract the
chain already rests on, and it degrades safely: the only thing that can
skip it is a claim ahead of it, which skips the expander too, so
nothing is queued in that fan-out either.

The other plausible home does not work and the comment now says why: a
subscriber registered beside `run_deferred` is too late, because the
whole nested fan-out completes inside the OUTER chain's subscriber,
before either of them runs.

Acceptance 45o pins the short-circuit path — a consumer at 25 that
claims when the record is nil, so the nested pass never reaches the
expander. 45n passes against this bug, which is why both exist.
Counting in the expander fails 45o and nothing else.

Framing rev 12 also names the shape rounds 10–12 share: each fix was
correct about the failure it was shown and wrong about the boundary of
the mechanism it leaned on — the chain's copy semantics, then its
re-entrancy, then its short-circuit. A queue that outlives the thing
that filled it has to name that thing, not approximate it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B
2026-07-26 17:15:24 -04:00
Levi Neuwirth 0d7ec7e3a6 fix(lean4): tie the deferred expansion to the fan-out that queued it
`buffer.after-edit` fan-outs NEST — the typed-edit contract supports a
consumer calling `pmacs.hook.run`, and typed_edit.lua's header says so
in its second paragraph. A nested run re-enters every subscriber,
including the deferred expansion's, while the OUTER chain is still
walking its consumer list and pairing has not yet seen the terminator.

So a consumer registered at priority 75 — between the expander at 50
and pairing at 100 — that runs one nested fan-out made `\alp(` yield
`α(` again: the nested pass consumed the queued expansion and edited,
and outer pairing then resumed holding a record the replace had
invalidated. That is round 10's failure reached through the chain's
documented re-entrancy seam rather than through claiming, which is why
deferring alone did not close it.

Deferring work past a fan-out means owning WHICH fan-out it belongs to.
The chain's subscriber and this module's each run exactly once per
fan-out, in that order, so counting invocations of the first and
matching them off in the second identifies the nesting level. Only the
outermost pass expands; a nested one leaves the expansion queued. No
new seam in typed_edit.lua, which is merged Stage 4a substrate.

Both halves bite: removing the level check and never counting
invocations each fail the new acceptance 45n.

Also fixes a test comment that still described the span design round 10
discarded — it claimed the expansion replaces the span "INCLUDING the
terminator". The behaviour asserted was right; the explanation was
stale. Framing rev 11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B
2026-07-26 17:00:58 -04:00
Levi Neuwirth 8af529b65d test(protocol): move the version ladder pins to v21
Both pins failed on the bump, which is what they exist for. The ladder
test now accepts 6..=21 and rejects 22, and the version assertion carries
the Stage 2 entry: four variants appended after their enum's final v20
variant, gated in both directions.

Also renames `protocol_version_is_twenty_for_gpu_initial_targets`, whose
name pinned the old number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR
2026-07-26 16:59:51 -04:00
Levi Neuwirth 640c5cd0d2 feat(panel): bottom-panel Stage 2B — the v21 protocol layer
Adds the four wire shapes Q#BP9 names, bumps the protocol to v21, and
factors the cell-grid validator so a panel frame shares the terminal's
rules without inheriting its PTY caps.

- `InstanceMessage::PanelFrame(PanelFramePayload)`, appended after
  `InitialTargetResult`; `Absent` is an explicit authoritative state, not
  silence, because the receiver retains its last valid frame.
- `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`,
  appended after `TerminalPointer`. Geometry is valid without a side
  window — gating it on panel presence would deadlock the first open,
  since the daemon needs columns before it can paint a first frame.
- `pmacs-protocol/src/wire_grid.rs` holds the shared rules: checked area,
  visible-cell bound, cell count, cursor bounds, glyph legality,
  wide-continuation topology, the aggregate glyph budget, and the
  attachment rejection. The 512 per-axis caps, metadata, selection spans,
  and the at_bottom/scroll_offset coupling stay terminal-only.
- The attachment rejection is deliberately shared despite its
  terminal-side wording: panels render no attachments either, so sharing
  it fails closed for both.

Both byte pins were falsified by revert: moving `PanelFrame` ahead of
`InitialTargetResult` shifts it 27 -> 28 and fails; moving the three
events ahead of `TerminalPointer` shifts it 12 -> 15 and fails.

The factoring changed no terminal acceptance — all 17 terminal tests pass
unchanged. It did surface a pre-existing coverage gap: those tests pin
the row cap but never the column cap, so widening `max_cols` to u32::MAX
left them green. `a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not`
now covers that direction.

The daemon projection, the epoch state machine, and the GPU band are
later slices of this stage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR
2026-07-26 16:52:54 -04:00