docs: retire the QoL arc's three lanes, re-homing their durable residue (#224)

* docs: retire the long-lines lane, the QoL arc having closed at #223

Rule 4, applied in its stated order: durable facts to
`docs/agent-handoff.md` FIRST, then the lane goes. The ledger is for
volatile branch/checkpoint/recovery state, and none of that survives a
merge worth keeping.

What moved to the handoff, because it is architecture rather than
bookkeeping:

  - The GPU is not a grid consumer, so it could not inherit
    `view_left`; `code_scroll_left` is its own, in pixels, local
    viewport state with no wire and no version bump.
  - The work was ONE transform and ONE clip, written before any
    consumer moved. glyphon honors `TextBounds` so the text layers
    clip themselves; the manual quad and squiggle renderers do not,
    and nothing needed them to before this stage because no
    code-relative x could be negative.
  - Washes crop rather than drop — the same boundary Stage 4's review
    caught the TUI painter getting wrong.
  - Both lifecycle resets are observed PRE-MOTION, because a later
    cursor motion repairs the offset anyway.
  - `pmacs_protocol:📜:follow_left`: the follow rule beside
    `classify`, called by both frontends. The approved exception to
    Stage 5's scope, recorded in the framing doc §1.2a.
  - The two witnesses that exist because mutation testing found the
    TESTS wrong rather than the code — the unwitnessed glyph layer, and
    the completion predicate a far-off-left test could not catch.
    "Test a boundary AT the boundary" is the transferable part.

The `## Long lines (QoL arc)` block is removed whole; nothing else in
the ledger referenced it.

NOT REMOVED, and not an oversight: the QoL Stage 1 (#219) and Stage 2
(#220) blocks. Rule 4 would now reach them too — the arc they belong to
is closed — but Stage 2 carries a live named follow-on, capability-aware
keymap resolution (Q#Z3 option A, deliberately deferred), plus the
"first eager state consumer" restore-seam finding. Those need re-homing
before their blocks can go, which is a judgment beyond retiring the lane
that was asked for. Flagged rather than done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai

* docs: complete the retirement — Stages 1 and 2, and the stale anchors

The previous commit removed the long-lines lane and stopped short. Two
things were still wrong.

STALE ANCHORS. The handoff was dated 2026-08-06 with `main` at
`db1bbe9` — five merges behind. Now 2026-08-08 at `9a26ac8`, with the
arc's five merges named in the ancestry chain, and the same in
active-work's canonical-base record and recovery floor (a tree at
`db1bbe9` no longer passes; it would lack the whole arc).

**The recovery path was re-exercised, not SHA-swapped.** That file
warns that advancing the base is exactly when the commands are most
likely to have rotted and that a swapped SHA reads identically to a
verified one — so: fresh clone into an empty directory, `githubsucks`
alias added, `git fetch --prune`, `9a26ac8` confirmed an ancestor of
`githubsucks/main`, and a worktree recovered with the three-argument
form. All four steps clean.

STAGE 1 AND STAGE 2 BLOCKS. Keeping them left false live planning
standing: "Stage 3 is long-line wrap/scroll, which is a design round:
no horizontal viewport exists at all" — written before #221–#223 built
one. A merged lane that still describes the future is worse than no
lane.

Re-homed first, per Rule 4's order:

  - **FG-INV is a CONSUMER contract**, and it lives on the protocol
    type because that is where consumer authors read it. It had been a
    doc comment on a PRIVATE PRODUCER FIELD, which is why the one
    consumer never honored it.
  - **Seven tests covered that flag and all seven tested the
    producer.** None asserted a consumer acts on it. "Add a test for
    the flag" had already been done — §5's enforcement/documentation
    drift in a second register.
  - **`install_state_dirs` is the eager-state-consumer seam.**
    Builtins and `init.lua` run before it, so `pmacs.state.read` at
    module load returns nothing, always. `saveplace` and `recentf`
    escape it only because both read lazily. Any future eager consumer
    belongs at the same seam.
  - **A GPU-only binding cannot be expressed**: `Scope` has no frontend
    identity and `FrontendEvent` no command-invocation variant. #220
    shipped commands without bindings for that reason, not preference.

  - **Capability-aware keymap resolution** is now a named §6 backlog
    item: CROSS-CUTTING, NOT STARTED, needs its own framing. It says so
    explicitly, and says not to start it as a half-lane attached to
    another stage's branch — which is how it would arrive by accident.
    No implementation, no lane, no design.

What is deliberately NOT preserved: the recovery commands for
`full-grid-resync` and `gui-zoom`. Those branches are merged; a
recovery command for a branch nobody should check out is the kind of
stale instruction this ledger exists to avoid. The framing docs remain
on disk as the historical record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai

* docs: the live schema range is v6..=v22, and #224 gets its own lane

Two review findings against the retirement.

THE SCHEMA RANGE. The canonical-base paragraph still said `v6..=v21`.
The upper bound moved to **v22 at #221**, which added
`InstanceMessage::LineWrapFacts` — so that line had been wrong for two
merges, including the one this branch is retiring the lane for.

Verified against `pmacs-protocol/src/message.rs` rather than carried
forward: `SUPPORTED_PROTOCOL_VERSIONS` is `6..=22`, `PROTOCOL_VERSION`
is 22, and `ADVERTISED_PROTOCOL_VERSION` is **20** and did not move.
The paragraph now says so, and says the advertised constant must not be
edited to chase the range — it is a permanent baseline, and the session
version is settled one message later by the frontend's counter-offer.

It also now states which claims it governs: historical `v21` statements
elsewhere describe a stage as it landed and are correct there. Only this
current-state paragraph tracks the live range, so only this one goes
stale when the range moves.

A LANE FOR #224. This file requires a lane for **every open PR**, and
the PR that retires other lanes is not exempt. Added with the branch,
the ref-not-a-SHA recovery command, the docs-only scope, and the
verification — including that the recovery path was re-exercised rather
than SHA-swapped, and that the full gate suite is deliberately not
re-run for a change that cannot reach it.

The entry is honest about its own lateness: it was written AFTER the PR
existed, which is the standing correction from #171 and #215 being
missed again, and it took review asking. Back-dating the block to look
compliant would have destroyed the only evidence that the practice
still slips. It carries its own retirement instruction — next
absorption after #224 merges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai

* docs: scope the two provenance paragraphs that claimed to be current

Both said "this line is the head-of-`main` anchor". Neither was, and a
provenance note that claims currency is worse than a plainly historical
one — it disagrees with the real anchor at the top of the file while
looking authoritative.

The bottom-panel paragraph keeps its `v6..=v21` facts, which are correct
for the stage they describe. What changes is scope: "a current session"
becomes "a session at that anchor", and the closing clause now says
these statements describe the historical `6c9e765` anchor, with the
live range pointed at "Repository authority" in `docs/active-work.md`
(`v6..=v22` since #221; advertised baseline still v20). The
counter-offer mechanism is called out as still current independent of
which numbers it carries, since that part did not go stale.

The second was MY inconsistency, introduced earlier on this branch. I
rewrote that bullet's opening to "Beneath the QoL arc, at `db1bbe9`"
and left its closing claiming to be the head-of-`main` anchor, so the
bullet contradicted itself. It now names what it actually is — the
`db1bbe9` ancestry chain — and points at the top of the file.

Both cross-references name the file they point into. "Repository
authority" is a section of `docs/active-work.md`, not of this one, and
an unqualified "above" would resolve to nothing here.

Noted, not acted on: the bottom-panel arc has its own currency drift in
this file (§1 says Arc 7 COMPLETE at #213, two later paragraphs still
call Stage 3 the remaining step). Pre-existing, unrelated to the QoL
retirement, and not this PR's to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-08-08 13:10:24 +00:00 committed by GitHub
parent 9a26ac8f9f
commit b833b139e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 185 additions and 485 deletions

View File

@ -111,7 +111,11 @@ lesson, §1 for the two framings).
are identical on every machine. Remote names are otherwise
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot: **`githubsucks/main` @ `db1bbe9`** —
- Canonical base at this snapshot: **`githubsucks/main` @ `9a26ac8`** —
GPU horizontal scroll **#223**, which **closes the QoL arc**, atop
`2b56d16` TUI horizontal scroll **#222**, `02f3ec3` `ui.line-wrap`
**#221** (protocol v22), `218d2e7` GUI zoom **#220** and `da56bec`
`full_grid` **#219**. Beneath those, `db1bbe9`:
the tree primitive **#217**, atop `2657568` the macOS CI
signal-integrity **Stage 2 #216** (which retired R2 and R4), atop
`12f2970` its Stage 1 registry **#215**, atop `f186253`:
@ -123,13 +127,20 @@ lesson, §1 for the two framings).
#206, Journey Stage 1b-3 #205, 1b-2 #204 and 1b-1 #203, the
reap-ledger diagnostic #202, the isolation framing #201, the
process-signal diagnostic #200 and the ledger absorption #199.
**Protocol schema support is `v6..=v21`; the production server-first
**Protocol schema support is `v6..=v22`; the production server-first
`Hello` still advertises v20** — two different facts, and #184 landed
only the first.
only the first. The upper bound moved to **v22 at #221**, which added
`InstanceMessage::LineWrapFacts`; `ADVERTISED_PROTOCOL_VERSION` did
not move and must not be edited to chase it. Verified against
`pmacs-protocol/src/message.rs`, not carried forward: this line said
`v6..=v21` for two merges after that stopped being true.
**Historical `v21` statements elsewhere in this file are correct
where they describe a stage as it landed** — only this
current-state paragraph tracks the live range.
**The recovery floor advances with the base**, so the check below
now requires `db1bbe9` or newer; a tree at `12f2970` no longer
passes — it would lack #216 and #217, both of which this file
describes as complete. That is deliberate — a check accepting an older commit than
now requires `9a26ac8` or newer; a tree at `db1bbe9` no longer
passes — it would lack the entire QoL arc, which this file and the
handoff both describe as complete. That is deliberate — a check accepting an older commit than
the declared base passes on a tree the rest of this file does not
describe.
**Lanes below that name an older base have not been re-based; derive
@ -167,7 +178,7 @@ git worktree list
git status --short --branch
```
The `git log` command must expose `db1bbe9` — the base named above — or a
The `git log` command must expose `9a26ac8` — the base named above — or a
newer intentional main. Keep this threshold and the canonical-base line in
step: a recovery check that accepts an older commit than the base it
declares canonical will pass on a tree the rest of this file does not
@ -175,12 +186,15 @@ describe.
If it does not, stop and repair the remote/fetch configuration.
**This path was exercised, not asserted, at this snapshot** — re-run
from an empty directory on 2026-08-06 when the base advanced to
`db1bbe9`, rather than having its SHA swapped. `git clone` the
canonical URL, add the `githubsucks` alias, `git fetch githubsucks
--prune`, confirm `db1bbe9` is an ancestor of `githubsucks/main`, and
recover a lane with the three-argument `git worktree add <path> -b
<local> githubsucks/<branch>` form. All four steps ran clean.
from an empty directory on 2026-08-08 when the base advanced to
`9a26ac8`, rather than having its SHA swapped. That distinction is the
whole point of this paragraph: advancing the base is exactly when the
recovery commands are most likely to have rotted, and a swapped SHA
reads identically to a verified one. `git clone` the canonical URL, add
the `githubsucks` alias, `git fetch githubsucks --prune`, confirm
`9a26ac8` is an ancestor of `githubsucks/main`, and recover with the
three-argument `git worktree add <path> -b <local> githubsucks/<branch>`
form. All four steps ran clean.
**Correction, found by re-running it.** This file claimed the
two-argument form fails for a remote-only branch with `fatal: invalid
@ -196,6 +210,41 @@ hazard in a shape that looks committed. **A documented error message
that never appears is worse than no documentation**, because the reader
waits for a signal that is not coming.
## QoL arc retirement — PR #224 OPEN (docs only)
**PR #224** — https://github.com/levineuwirth/pmacs/pull/224. Written
**after** the PR existed rather than with the lane's first commit —
which is the standing correction from #171 and #215 being missed again,
and it took review asking. Recorded that way rather than quietly
back-dated: this file requires a lane for **every open PR**, including
the PR that retires other lanes.
- **Branch `retire-long-lines-lane`**, base `githubsucks/main` @
`9a26ac8` (the #223 merge). **`githubsucks/retire-long-lines-lane` is
the authoritative tip** — the ref, not a SHA, since any edit to this
block advances past whatever SHA it records. Recover with
`git fetch githubsucks && git checkout retire-long-lines-lane`.
- **Docs only.** Two files, `docs/active-work.md` and
`docs/agent-handoff.md`. No `src/`, no crate, no manifest, no test
changes.
- **Scope:** Rule 4 for the QoL arc, closed at #223. Remove the three
merged lane blocks (long lines, Stage 1 #219, Stage 2 #220) **after**
re-homing their durable residue to the handoff; advance the handoff's
date and `main` anchor and this file's canonical-base record and
recovery floor; add capability-aware keymap resolution as a named
handoff §6 backlog item — cross-cutting, **not started**, needs its
own framing.
- **Verification:** `git diff --check` clean. **The recovery path was
re-exercised, not SHA-swapped** — fresh clone into an empty
directory, `githubsucks` alias, `--prune` fetch, `9a26ac8` confirmed
an ancestor of `githubsucks/main`, worktree recovered with the
three-argument form; all four steps clean. Swept for dangling
references to the removed lanes and their branches. The full gate
suite is **not** re-run for a change that cannot reach it, and that
is stated rather than left as a gap.
- **Retire this block in the next absorption after #224 merges.** It
describes a docs PR; once merged there is nothing volatile left.
## Docs absorption after #217 — MERGED as #218 (2026-08-06 09:59Z)
**PR #218** — https://github.com/levineuwirth/pmacs/pull/218. **This
@ -225,162 +274,6 @@ are the lanes this one creates, not work it does. Retiring the
CI-CRDT, Distribution, or reap-ledger lanes: each still owns undone
work and rule 4 does not apply to them.
## Honoring `full_grid` (QoL Stage 1) — MERGED as #219 (2026-08-06 13:41Z)
**PR #219** — https://github.com/levineuwirth/pmacs/pull/219. **This
block was written with the lane's first commit, before the PR
existed** — the standing correction from #171 and #215 — so the row
below was filled in rather than invented.
- **Branch `full-grid-resync`**, base `githubsucks/main` @ `da56bec`
(the #218 merge). `githubsucks/full-grid-resync` is the
authoritative tip; any edit to this block advances past whatever SHA
it records. Recover with `git fetch githubsucks && git checkout
full-grid-resync`.
- **Framing `docs/full-grid-resync-framing.md` revision 2**, approved
with **Q#FG1 = A**: the sole grid consumer honors the flag by
resetting style, clearing, then applying spans.
- **First of three QoL stages**, from daily-driver use. Stage 2 is GUI
zoom (the machinery exists — `FontMetrics::scale` already derives
every dimension and is driven by an attach message in centi-pixels;
it is unbound and unpersisted). Stage 3 is long-line wrap/scroll,
which is a design round: **no horizontal viewport exists at all**
(`view_left` / `col_offset` / `hscroll` match nothing in `src/` or
`pmacs-gpu/src/`). Separate branches on purpose — Stage 1 is a
contained fix and must not wait behind Stage 3's design.
### What it ships
`FG-INV` moves onto the protocol type, where consumer authors read it:
a `full_grid: true` delta carries only the frame's **non-default**
cells, so a consumer MUST blank its surface first. The rule already
existed — in the doc comment of a **private field** on the producer's
struct (`src/instance_render.rs:36`), which is why the one consumer
never honored it.
`emit_cell_delta` joins the existing pure escape-sequence helpers in
`src/frontend.rs` (`emit_span`, `emit_status_overlay`, …), and
`apply_message` routes through it.
### Why a green suite missed it for so long
Seven tests cover the flag. Every one asserts the **producer sets it**;
none asserted a **consumer acts on it**, and no runtime reader existed
anywhere in the workspace. "Add a test for the flag" had already been
done. This is handoff §5's *enforcement and documentation drift apart
silently* in a second register, and it is why the fix ships the
contract and the consumer together.
### Verification
- Three unit witnesses, all bitten: order (reset → clear → spans),
**empty spans still clear**, and a differential frame clears never.
The empty-spans case discriminates on its own — under the plausible
`spans.is_empty()` early return the order test still passes and only
that one fails.
- PTY acceptance (`tests/full_grid_resync_acceptance.rs`) drives a real
`SIGWINCH`. **The mark is anchored to content, not time**: a
time-based settle cannot work because a settled pmacs screen emits
per-frame bytes forever. Bitten against the original defect: 34,831
bytes after the first painted frame with no `CSI 2 J`.
- **What it does not prove:** the suites assert on raw bytes; there is
no screen model and no `vt100`/`termwiz`/`vte` dependency. This shows
pmacs *emitted* a blank at the right moment, not that the screen
ended correct. A terminal emulator in test deps is a candidate, not
smuggled in here.
### Not in scope
Stages 2 and 3. The `pmacs.terminal` child-PTY `SIGWINCH` path. Any
change to *when* `needs_full_grid` is set — the producer's triggers
were verified correct, along with per-frame geometry sync and
`view_top` reconciliation on shrink.
## GUI zoom (QoL Stage 2) — MERGED as #220 (2026-08-07 08:25Z)
**PR #220** — https://github.com/levineuwirth/pmacs/pull/220. **Written
with the lane's first commit, before the PR existed** — the standing
correction from #171 and #215 — so the row below was filled in rather
than invented.
- **Branch `gui-zoom`**, base `githubsucks/main` @ `218d2e7` (the #219
merge). Pushed; **`githubsucks/gui-zoom` is the authoritative tip** —
the ref, deliberately not a SHA, since writing one into the commit
that updates this lane makes it stale in that same commit.
Recover: `git fetch githubsucks && git checkout gui-zoom`.
- **Framing `docs/gui-zoom-framing.md` revision 5**, approved after
four review rounds; revision 5 adds §3.2 for a finding raised against
the implementation. **Q#Z1 = (c)** configured base with `None`
preserved; **Q#Z2 = additive**; **Q#Z3 = (C)** commands only, no
default bindings; **Q#Z4** eager restore inside `install_state_dirs`.
### What it ships
`builtin/runtime/zoom.lua`: two settings (`ui.gpu-font-size-base`,
`ui.gpu-zoom-step`), three commands (`gpu.zoom-in` / `-out` /
`-reset`), and `pmacs.zoom.restore` called from `install_state_dirs`.
**No rendering work** — `FontMetrics::scale` already derived every GUI
dimension and `apply_font_facts` already re-metriced everything; this
drives the preference that existed.
### The findings review caught, none of which was in revision 1
- **Q#Z3 was not implementable.** `keymap_stack::Scope` is
`Buffer | Mode | Global` with no frontend identity, and
`FrontendEvent` has no command-invocation variant — so neither "bind
on GPU only" nor "the GPU asks for a command" exists. Commands ship;
the binding waits on **capability-aware keymap resolution**, now a
named follow-on.
- **The restore seam did not exist.** Builtins and `init.lua` both run
*before* `install_state_dirs`, so a `pmacs.state.read` at module load
returns nothing, always. `saveplace` and `recentf` never meet this
because **both read lazily**; zoom must apply with no user action,
making it the **first eager state consumer**. Restore lives at the
end of `install_state_dirs` — by definition when state becomes
readable, so it cannot be mis-ordered or missed by a future third
startup path.
- **Every size write clobbered the family.** `set_font` replaces both
fields unconditionally, so `{ size = n }` alone silently cleared a
configured family until restart.
- **The bounds could not carry the round-trip guarantee** (raised
against the implementation, not the framing). `ConfigKind::Number`
validates finiteness and bounds and *nothing else*, and `on_change`
is notified after the value is stored — so it cannot veto. A step of
`0.015` is therefore settable, and used raw it broke the framed
exact round trip: `16.00 -> 16.02 -> 16.01`. Fixed by quantizing the
step **and** the base at the point of use, which is the operation
`validate_font_size` already applies to sizes, one level up.
Set-time enforcement was rejected: the registry cannot express
precision, and a validating wrapper is bypassed by a direct
`pmacs.config.set` — the seam `autosave` documents about
`interval_ms`.
A fifth, documentation-only: the explanation of *why* `0.015` broke
said the two intermediates rounded in opposite directions. They do not.
`16.015` and `16.005` are exactly `1601.5` and `1600.5` centi-pixels —
both exact ties, and half-up sends **both up**. The mechanism is that
half-up is not symmetric under negation, so the two roundings
accumulate rather than cancel. Corrected in all three copies; no
behavior change.
### Verification
15 acceptance tests, four bitten: dropping family preservation fails
3; reverting to the framing's first parser `^(%d+)$` fails 4 including
the seam restore (it anchors to end-of-subject and rejects the
newline-terminated file the writer emits); hardcoding the 16.0 origin
fails the base test; **using the raw step instead of the quantized one
fails the unrepresentable-step witness** with
`left: Some(16.01) / right: Some(16.0)` **while the pre-existing 0.37
test still passes** — which is exactly why the new case had to be its
own test rather than another parameter of that one.
### Not in scope
Stage 3 (long lines). Capability-aware keymap resolution — Q#Z3's
option (A), deliberately deferred rather than half-built. Per-buffer
zoom. Any change to `FontFacts` or the wire.
## Empty-content readiness, a fourth and fifth instance — FOR THE R6 AUDIT
Found 2026-08-06 while gating this lane, recorded here because it
@ -435,293 +328,6 @@ Whether `docs/ci-red-signatures.md` should grow a short non-row section
for this class is an open question for its owner, not something this
lane decided.
## Long lines (QoL arc) — Stages 3 and 4 MERGED (#221, #222); Stage 5 closes it
**Rewritten, not removed.** Rule 4 removes a lane when its ARC is done;
this one has **Stage 5** ahead. The durable facts of Stages 3 AND 4 are
both in `docs/agent-handoff.md` §1 — rule 4's precondition, satisfied
rather than deferred — so what remains here is the Stage 5 plan and
only the residue from earlier stages that constrains it.
> **RULE 4 APPLIES AT STAGE 5's MERGE, AND NOT BEFORE.** The arc closes
> at Stage 5 (GPU horizontal scroll). Q#HS1 split the GPU out
> deliberately and time-boxed it; retiring this lane at Stage 4's merge
> would have orphaned exactly the half the time box exists to
> guarantee, while `truncate` was still a dead end in the GUI. **Do not
> remove this block until Stage 5 has merged** — and when it does, the
> handoff bullets are what makes removal legitimate rather than lossy.
**Branch `horizontal-scroll`**, based on `githubsucks/main` @ `02f3ec3`
(the #221 merge). `githubsucks/horizontal-scroll` is the authoritative
tip — the ref, not a SHA, since any edit to this block advances past
whatever SHA it records. Recover:
`git fetch githubsucks && git checkout horizontal-scroll`.
**Stage 4 MERGED as #222** (`2b56d16`). Long lines are now reachable
**in the TUI**; the GUI half is Stage 5.
**Branch `gpu-horizontal-scroll`**, based on `githubsucks/main` @
`2b56d16`. `githubsucks/gpu-horizontal-scroll` is the authoritative tip
— the ref, not a SHA. Recover:
`git fetch githubsucks && git checkout gpu-horizontal-scroll`.
**Status: PR #223 OPEN, awaiting CI. DO NOT MERGE until the user says
so.** `https://github.com/levineuwirth/pmacs/pull/223`. Framing revision
4 approved 2026-08-07; review round 1 answered 2026-08-08 (the
completion point-predicate defect below).
**The tip is the ref, `githubsucks/gpu-horizontal-scroll`, not a SHA
written here.** The first version of this line pinned `55faa45` — which
the very commit that wrote it invalidated, because recording the PR
moved the head. Verify CI against the PR's live `headRefOid`, never
against a SHA quoted in a document.
G1 pixels (exact conversion via the
supported monospace advance); G2 automatic cursor-follow only, zeroing
on **both** wrap transition and `BufferSnapshot`; G3 monospace-only by
the existing font contract; G4 minimap unchanged; G5 accepted whole —
**all twelve witnesses written and mutation-tested** (see below).
**Scope: local GPU viewport state.** No wire message, no protocol bump,
no command surface, no minimap movement.
**ONE APPROVED EXCEPTION TO THAT SCOPE** (user, 2026-08-08; recorded in
the framing doc at §1.2a). Q#G5's
TUI-parity witness asks for agreement that is "checkable rather than
asserted". Two tests in two crates asserting the same literal is not
that — it is exactly the structural duplication
`pmacs-protocol::scroll`'s own module docs condemn, and that module
exists because **this arc already shipped that defect** (the scroll
indicator, fixed in one copy and left wrong in the other). So the follow
rule moved to `pmacs_protocol::scroll::follow_left`, beside `classify`,
and **both** frontends call it: `src/editor.rs::horizontal_follow`
delegates, and the GPU converts px ↔ columns around it (exact by Q#G3).
What it costs: Stage 5 touches `src/editor.rs`, which the scope line
above does not cover. What it buys: the two frontends *cannot* choose
different edges. The approval turned on what it does **not** do — it
moves no viewport state, adds no wire message, and needs no
protocol-version bump.
**Review round 1 (2026-08-08) — one non-zero-offset defect, fixed.**
`completion_anchor_px` reused `survives_code_clip_left` and passed
`line_height` as the horizontal extent: **a vertical dimension standing
in for a horizontal one**. An anchor up to a line-height left of the
gutter survived, and `completion_dropdown_rect` clamps `ax` against the
right margin only — so the popup painted over the line numbers. Now a
point predicate, `screen_x < code_clip_left()`.
**The lesson is about the witness, not the predicate.** The existing
test placed the anchor 200px off-left, which fails a width-based
predicate too — it stayed green straight through the defect and the
mutation battery agreed with it, because the battery only ever asked
whether *removing* the check was caught. **A boundary this stage cares
about must be tested AT the boundary**: the replacement straddles the
edge by ±0.05px and additionally asserts the popup's own left edge stays
out of the gutter, which is what makes "`completion_dropdown_rect` needs
no left clamp" a checked claim rather than a comment.
**Its first finding corrects Stage 4's framing.** §1.3 there said the
GPU "needs a mechanism that does not exist", and I endorsed the
Stage 4/5 split partly on that basis. Half of it was right —
`Scroll::horizontal` really is discarded, because glyphon 0.11 never
applies it — but the document `TextArea` already carries an explicit
`left` origin and a `TextBounds` clip, and shifting that origin is the
same "paint from 0, clip at the edge" shape the grid uses. The split
stays right (the three consumers below are real work), but it was
justified partly by an overstatement.
The real work is applying **one** offset to the three consumers Stage
4's framing did name correctly: the caret (`code_byte_px`), decoration
geometry (`push_glyph_extent_rects`), and hit testing
(`gutter_aware_rel_x`). **No wire and no version bump** — the GPU owns
its viewport locally, exactly as it owns `scroll_top`.
**What Stage 4 shipped**, beyond the `view_left` contract below:
- **`Viewport::visible_cols` — one clip rule, five adopters.** Review
found the first version had translated the base glyph walk and
nothing else, so syntax styling, diagnostic underlines, search
washes, `BufferStyleOverlay` and the selection painter all kept
painting at absolute columns: decorations drifting off the characters
they describe, only once a window had been scrolled. The selection
painter was worst — it asked `pos_to_display` through the live
context, which returns `None` left of the edge, so a selection
starting off-screen painted **nothing at all**.
A second review round caught that my reason for letting selection
keep its own copy of the rule (a width the viewport supposedly
lacked) was **false**: the render viewport is already
`rect.size.cols - gutter_w` with an origin past the gutter. It now
takes that viewport. `StyleSpanOverlay` / `VirtualCellOverlay` stay
untouched — viewport-relative by contract.
- **The `ui.line-wrap` description now names what `truncate` costs**,
closing the #221 gap where only the toggle's status message said it.
- **`R7`** in `docs/ci-red-signatures.md` — an unrelated, unreproduced
`pmacs-gpu` managed-retry `BrokenPipe` under full-sweep load. First
incident this session with a complete signature, so a matchable row
rather than a `U` note.
**What Stage 5 shipped, beyond the offset itself:**
- **`crop_to_code_clip_left`, and `survives_code_clip_left` delegating
to it.** One boundary rule, so a caret the crop would discard is never
painted. The washes **crop** rather than drop — a selection running in
from off the left edge must paint the part that IS visible, which is
the same boundary Stage 4's review caught the TUI painter getting
wrong.
- **Twelve witnesses, each mutation-tested.** Eleven production
mutations (unshifted wash x, uncropped wash, unshifted math origin,
uncropped math rule, untested caret left edge, missing snapshot reset,
missing wrap reset, unhidden completion anchor, unscrolled glyphs,
inverted hit-test sign, pixel-instead-of-column snap) each fail the
intended witness as an **assertion** failure, not a compile error.
The minimap-stability witness was mutation-tested separately by
threading the offset into `minimap_vertex_bytes`.
- **The glyph-motion witness exists because the first pass lacked it.**
The gutter byte-identity test's "the code area must actually have
moved" assertion is satisfied by a decoration wash and the caret
alone: it **passed with `TextArea.left` pinned to `text_left`**. The
mutation battery caught that, not review. Its replacement isolates the
glyph layer — no decorations, and a source line carrying no caret.
- **`R8`** in `docs/ci-red-signatures.md` — a **deterministic**,
pre-existing `m4_acceptance` listview failure, confirmed on `main` by
merge-base control. Not this lane's, and deliberately not fixed here.
**Answered by the user 2026-08-07:**
- **Q#HS1 — the GPU is Stage 5, not Stage 4.** A conscious, bounded
divergence rather than a repeat of Stage 3's accidental one. The time
box is concrete: Stage 5 is the *immediately-next* QoL lane after
Stage 4 merges, `wrap` stays the default until it lands, Stage 4's
release notes state the asymmetry, and the `truncate` affordances
name the GUI gap while it exists.
- **Q#HS2 — automatic only.** The cursor-visibility pass gains a
horizontal component; no commands, no bindings, no new interaction
island. Explicit `ui.scroll-*` is deliberately out.
- **Q#HS6 — `wrap` stays the default.** Coupled to Q#HS1: with the GPU
deferred, a `truncate` default would ship a mode that is navigable in
the TUI and a **dead end in the GUI** for anyone who never opened the
setting. Revisit after Stage 5, on use evidence.
- **Q#HS7 — ACCEPTED.** `view_left` is an unsnapped window display
column; the effective edge is derived **per line**; a bisected wide
glyph's trailing cell renders as styled blank and is designated to
the **glyph's start** byte; a straddling tab's surviving cells keep
the **existing** forward rounding to the byte after the tab
(`src/text_view.rs:224` — preserved, not chosen). Together these make
the mapping **total over visible cells**, which is the (d) invariant.
The discriminating witness is multi-line, with glyph widths differing
at the same column.
- **Q#HS5 — APPROVED: yes, persist, no `DESKTOP_VERSION` bump**
conditional on `#[serde(default)]` **and** a literal v1 JSON fixture
omitting the field, asserting restore at zero. Both conditions are
part of the approval.
- **Q#HS3** is re-confirmed rather than open (per-window, per Q#LL2);
**Q#HS4** is deferred, live only if explicit commands arrive.
**The reasoning worth keeping from the withdrawn Q#HS7(c).** Revision
2 voted to snap `view_left` to a valid boundary when set. That cannot
exist, and the reason generalizes: `view_left` is ONE per-window
column, but *"does column N bisect a wide glyph?"* is a **per-line**
question. No setter-time value is canonical for every visible line,
and snapping per line instead would break the vertical alignment a
column-oriented view exists to provide. Recorded because the same trap
waits for any future window-wide value derived from per-line content.
**One correction carried into revision 2.** Revision 1 claimed the
"unreachable past the edge" caveat is recorded in the setting's
description. It is not — `builtin/runtime/linewrap.lua:23` says only
"truncate at the edge"; the word appears in the toggle's status message
and a source comment, neither of which a user sees if they set the mode
in `init.lua`. **A real, small user-facing gap shipped in #221**;
amending the description is now a Stage 4 deliverable (framing §6).
### What Stages 3 and 4 shipped that Stage 5 must live with
- **`ui.line-wrap` is BUFFER-local** (`ConfigKind::Enum`,
`wrap`/`truncate`, default `wrap`). Q#LL2 recorded the consequence
and deliberately deferred it: `view_left` is unambiguously
**per-window**, because two panes on one buffer must scroll
independently exactly as they already hold independent `view_top`s
(`src/desktop.rs:92`, `src/window.rs:374`). So the two halves of one
user-facing concept land at different scopes. Emacs effectively does
this and it is survivable — but Stage 3 signed up for it as *a
decision*, and Stage 4 is where the bill arrives.
- **`truncate` is the mode Stage 4 makes navigable.** Today text past
the right edge is not merely off-screen but **unreachable** — and
that is stated **only** in `ui.toggle-line-wrap`'s status message and
a source comment, **not** in the setting's description
(`builtin/runtime/linewrap.lua:23` says just "truncate at the edge").
A user who sets the mode in `init.lua` and never invokes the toggle
is told nothing. Amending the description is a Stage 4 deliverable
(framing §6); the status message is revisited when scroll lands.
- **Under `wrap`, horizontal scroll is meaningless.** Stage 4's surface
is therefore conditional on the mode, which is a coherence question
(one concept, two behaviors) and not only an implementation one.
- **The scroll indicator's `wrap` path takes byte percentages** from
`pmacs-protocol::scroll`. It is vertical-only and Stage 4 does not
change it — recorded because "scroll" in this lane means horizontal
and the two must not be conflated in review.
### Ground truth gathered for the framing (verify before trusting)
- **There is no horizontal scroll anywhere in the tree.** No
`view_left`, `scroll_left`, or `hscroll` in `src/` or `builtin/`.
This is greenfield, not an extension.
- **`paint_line` starts every walk at column 0** (`src/text_view.rs`),
which is the same walk Stage 3 rewrote for wrapping. A `view_left`
enters here, and the wrap rule (`advance_wrapped`) must stay written
exactly once.
- **The GPU cannot honor horizontal scroll through cosmic-text.**
`Scroll::horizontal` is discarded throughout, because **glyphon 0.11
never applies it when placing glyphs** — documented at
`pmacs-gpu/src/main.rs:1611`, `:6316`, `:8020` and asserted by tests
at `:16266`, `:16337`, `:16737`. The GPU's Stage 4 half needs a
different mechanism entirely. **This is the fact most likely to
invert the cost estimate**, exactly as the "both frontends consume
the same `CellGrid`" error did in Stage 3 revision 1.
- **`view_top` is persisted per leaf** in `SavedLeaf` alongside
`cursor`, at `DESKTOP_VERSION = 1` (`src/desktop.rs:33`). A
`view_left` that survives a restart needs either a defaulted field or
a version bump — a decision, not an afterthought.
- **`scroll_window` carries the cursor with the scroll** to defeat the
renderer's "auto-scroll to keep cursor visible" pass, whose comment
already records the hazard: an unconditional snap-back makes explicit
scrolling feel stuck (`src/editor.rs:3624-3628`). A horizontal analog
faces the identical problem, and Q#LL3 deferred the choice — drag the
cursor, or let the next motion snap back — to this stage.
- **`goal_col` is the existing column-memory field**
(`src/window.rs:376`, cleared at seven sites in `src/editor.rs`). Its
relationship to a horizontal offset is unexamined and is a framing
question, not an implementation detail.
### Gate note this lane inherits
Stage 3 put eight broken version assertions on CI by running
`CLAUDE.md`'s short gate list instead of `docs/agent-handoff.md` §3's,
which includes a full sweep. **§3 is the authority.**
**Stage 4 should not need a protocol bump at all** — Q#HS1 puts the GPU
in Stage 5, and `view_left` is per-window TUI state with no wire. If
that changes, §3's protocol-bump form is
`cargo test --workspace --no-fail-fast -- --skip basedpyright` in
**both** feature configurations.
**`--workspace`, not `--tests`**, and the distinction is not cosmetic:
`--tests` selects 108 targets where `--workspace` selects 110, and the
two it drops are **`pmacs_protocol` and `pmacs_gpu`**. Stage 3's own
*remediation* sweep used `--tests`, so it never ran `pmacs-protocol`'s
25 tests — including the `scroll::classify` tests that lane had just
written. They passed, but by luck, and a correction that reproduces the
shape of its own mistake is worth naming.
### Not in scope
`M-q` / auto-fill / reflow. Word wrap as a mode value — a named future
third choice. Bidi/RTL. Soft-wrap gutter indicators.
## Tree primitive (P5) — MERGED as #217; adoption is the open work
**The lane is gone, not the work.** Rule 4 removes a lane after merge,

View File

@ -1,12 +1,15 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-08-06.** `main` is **`db1bbe9`** — the tree
primitive **#217**, atop **#216**, which completed the macOS CI
signal-integrity arc by retiring R2 and R4 with discriminating
witnesses, atop **#215**, which built the registry. **That arc is
retired**; its live residue (R1, R3, and the newer R5 and R6) is
re-homed to the async-runtime, reap-ledger, and readiness-helper-audit
lanes in `docs/active-work.md`.
**Last updated: 2026-08-08.** `main` is **`9a26ac8`** — GPU horizontal
scroll **#223**, which **closes the QoL arc** (§1). Beneath it the arc's
other four: **#222** TUI horizontal scroll, **#221** `ui.line-wrap` at
protocol v22, **#220** GUI zoom, **#219** `full_grid` honored by the
grid consumer. Beneath those, `db1bbe9` — the tree primitive **#217**,
atop **#216**, which completed the macOS CI signal-integrity arc by
retiring R2 and R4 with discriminating witnesses, atop **#215**, which
built the registry. **That arc is retired**; its live residue (R1, R3,
and the newer R5 and R6) is re-homed to the async-runtime, reap-ledger,
and readiness-helper-audit lanes in `docs/active-work.md`.
The live CI-triage rule in §5 points at `docs/ci-red-signatures.md`,
which keys on **signature, not test name** — and, since 2026-08-06,
@ -83,17 +86,48 @@ reads it the way you just did.
For volatile branches, checkpoints, verification, and recovery
commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-08-07)
## 1. Where the project stands (2026-08-08)
- **QoL arc — Stages 1-4 merged (#219, #220, #221, #222); Stage 5
remains, and the arc closes there.** From one daily-driver report:
terminal zoom broke TUI rendering and did nothing in the GUI, and a
long line was unreadable past the edge.
- **QoL arc — CLOSED. All five stages merged (#219, #220, #221, #222,
#223).** From one daily-driver report: terminal zoom broke TUI
rendering and did nothing in the GUI, and a long line was unreadable
past the edge. Both complaints are answered on both frontends.
- **#219** made the grid TUI honor `full_grid`, so a post-resize
resync blanks the host before repainting.
- **`FG-INV` is a CONSUMER contract and lives on the protocol
type.** A `full_grid: true` delta carries only the frame's
**non-default** cells, so a consumer MUST blank its surface
before applying them. The rule already existed — in the doc
comment of a **private field on the producer's struct** — which
is exactly why the one consumer never honored it. An invariant a
consumer must satisfy belongs where consumer authors read it.
- **Seven tests covered the flag and all seven tested the
producer.** Every one asserted the producer *sets* it; none
asserted a consumer *acts* on it, and no runtime reader existed
anywhere in the workspace. "Add a test for the flag" had already
been done. §5's *enforcement and documentation drift apart
silently*, in a second register — and the reason a fix ships the
contract and its consumer together.
- **#220** gave the GUI native zoom over the font preference that
already existed, quantizing the step so the round-trip guarantee is
exact rather than approximately true.
- **`install_state_dirs` is the eager-state-consumer seam.**
Builtins and `init.lua` both run *before* it, so a
`pmacs.state.read` at module load returns nothing, **always**.
`saveplace` and `recentf` never meet this because **both read
lazily**; zoom must apply with no user action, making it the
**first eager state consumer**. Restore therefore lives at the
end of `install_state_dirs` — by definition the moment state
becomes readable, so a future third startup path cannot
mis-order or miss it. **Any future eager consumer belongs at the
same seam.**
- **A GPU-only key binding cannot be expressed today.**
`keymap_stack::Scope` is `Buffer | Mode | Global` with **no
frontend identity**, and `FrontendEvent` has no
command-invocation variant — so neither "bind on the GPU only"
nor "the GPU asks for a command" exists. #220 shipped commands
and no default bindings for that reason, not by preference. See
the capability-aware keymap resolution backlog item in §6.
- **#221** added **`ui.line-wrap`** — `ConfigKind::Enum`
(`wrap`/`truncate`), default `wrap`, **buffer-local**, with
`ui.toggle-line-wrap`. Both frontends honor it: the grid renderer
@ -164,17 +198,49 @@ commands, read `docs/active-work.md` immediately after this file.
before the field existed. A literal v1 JSON fixture guards it.
- **No wire.** `view_left` is per-window viewport state, so no
protocol message and no version bump.
- **Stage 5 is the GPU half** — a split decided rather than inherited
(framing Q#HS1), time-boxed: it is the immediately-next QoL lane,
and **`wrap` stays the default until it lands**, which keeps the
divergence invisible to anyone who has not opted in. Framing in
`docs/gpu-horizontal-scroll-framing.md`.
- **Stage 5 (#223) is the GPU half, and it closed the arc.** A split
decided rather than inherited (framing Q#HS1). Framing in
`docs/gpu-horizontal-scroll-framing.md`. The GPU is not a grid
consumer, so it could not inherit `view_left`; it has its own
`code_scroll_left`, in **pixels**, local viewport state with no
wire and no version bump.
- **The work was one transform and one clip, written before any
consumer moved.** `code_x_to_screen` / `screen_x_to_code` (exact
inverses) and `code_clip_left` / `crop_to_code_clip_left`.
**glyphon honors `TextBounds`, so the text layers clip
themselves; the manual quad and squiggle renderers do not** — and
nothing needed them to before this stage, because no
code-relative x could be negative. Five call sites deriving the
offset independently is how the caret and its glyphs come to
disagree.
- **Washes crop, they do not drop.** A selection running in from
off the left edge must paint the visible part — the same boundary
Stage 4's review caught the TUI painter getting wrong.
- **Two lifecycle resets, both observed pre-motion**: the wrap
transition and the `BufferSnapshot`. A later cursor motion
repairs the offset anyway, so a witness that waits for one cannot
tell "reset on snapshot" from "repaired on first motion".
- **`pmacs_protocol::scroll::follow_left`** — the follow rule now
lives beside `classify` and **both frontends call it**;
`src/editor.rs::horizontal_follow` delegates, and the GPU
converts px ↔ columns around it (exact, because non-monospace
code fonts are rejected). An approved exception to the stage's
"local GPU viewport state" scope, recorded in the framing doc
§1.2a. It moves no viewport state, adds no wire message, needs no
version bump.
- **Two witnesses exist because mutation testing found the TESTS
wrong, not the code.** The gutter byte-identity test's "the code
area must have moved" assertion is satisfied by a wash and the
caret alone, so it **passed with `TextArea.left` pinned to the
code origin** — the whole glyph-side mechanism was unwitnessed.
And the completion predicate passed `line_height` as a
*horizontal* extent, which the far-off-left test could not catch
because 200px off-left fails a width-based predicate too.
**Test a boundary AT the boundary**; the replacements straddle by
±0.05px.
**Rule 4 removes the long-lines lane when Stage 5 merges** — the
arc closes there, and these bullets are the precondition it depends
on.
- **`main` @ `db1bbe9`.** The **tree primitive #217**`listview` rows
- **Beneath the QoL arc, at `db1bbe9`** (it was `main` until #219).
The **tree primitive #217**`listview` rows
take optional `depth`/`id`, collapse is primitive-owned, folding is
**local projection state and not a refresh protocol**, and the LSP
outline is the sole adopter (`COHERENCE.md` §14 ◐; §20 says adoption,
@ -191,7 +257,9 @@ commands, read `docs/active-work.md` immediately after this file.
reap-ledger diagnostic #202, Journey Stage **1b-1** #203, **1b-2**
#204 and **1b-3** #205, the ambient-root isolation **implementation**
#206, and discovery Stage 1 #207. Each has its own bullet below; this
line is the head-of-`main` anchor and nothing else.
line is the `db1bbe9` ancestry chain and nothing else. **The
head-of-`main` anchor is at the top of this file** — it moved to
`9a26ac8` at #223, and this bullet's own opening says so.
- **Bottom panel Arc 7 COMPLETE — Stage 3 (#213), the adopter default
flip.**
Omitting `display` now means the panel for listview, compile and
@ -588,12 +656,21 @@ someone forgot.
framing #164, COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160,
minimap blank-slab #159, bottom-panel Stage 1 #155).
**Protocol schema support is `v6..=v21`, the production server-first
`Hello` advertises v20, and a current session nevertheless negotiates
v21.** All three are true at once, and Stage 2B-3 is what made them
compatible: the advertised version is a permanent **baseline** and the
session's real version is settled one message later by the frontend's
`AttachRequest` counter-offer. The bullets below describe the arcs in
their own terms; this line is the head-of-`main` anchor.
`Hello` advertises v20, and a session at that anchor nevertheless
negotiates v21.** All three are true at once, and Stage 2B-3 is what
made them compatible: the advertised version is a permanent
**baseline** and the session's real version is settled one message
later by the frontend's `AttachRequest` counter-offer — a mechanism
that is still current, independent of which numbers it carries. The
bullets below describe the arcs in their own terms.
**These statements describe the historical `6c9e765` anchor; the live
protocol range is recorded under "Repository authority" in
`docs/active-work.md`** (`v6..=v22` since #221 added `LineWrapFacts`;
the advertised baseline is still v20). This line previously called
itself "the head-of-`main` anchor", which stopped being true at #219
— so a provenance note read as a current-state claim, and disagreed
with the anchor at the top of this file.
- **`COHERENCE.md` is now required reading and a required framing input
#163.** It carries the product-coherence thesis, an audited
scorecard, per-concern gaps, and §20's priority order, and it is the
@ -2845,6 +2922,23 @@ round-trip cannot detect a discriminant shift.
## 6. Named deferrals (the standing backlog, consolidated)
**Capability-aware keymap resolution — CROSS-CUTTING, NOT STARTED,
needs its own framing.** Named here because #220 hit its absence and
worked around it, not because any of it is designed. Today
`keymap_stack::Scope` is `Buffer | Mode | Global` with **no frontend
identity**, and `FrontendEvent` carries no command-invocation variant.
Two consequences already paid for: **#220 could ship no default zoom
bindings** (`gpu.zoom-in` / `-out` / `-reset` are commands only), and
nothing can express "this binding exists only where a GPU is
attached".
It is genuinely cross-cutting — it touches the keymap stack, the
frontend event vocabulary, and what a capability *is* — so it is a
framing round before it is a lane. **Do not start it as a half-lane
attached to some other stage's branch**, which is how it would arrive
by accident; the workaround (commands without bindings) is stable and
costs nothing while it waits.
Editing: word kills (`M-d`/`M-BS` — need bytes-returning deleters +
prepend-on-backward append), `C-SPC` set-mark, `C-u C-y` / `C-M-w`,
kill-ring browser + persistence, clipboard watching, block comments +