Review round three on PR #235, plus the diagnosis of its first CI run:
all five test legs failed on one test, deterministically, while
sixteen local cores stayed green.
The mid-walk cancellation bound could not bite the per-entry poll
alone. The cancel lands two files into a 41-file directory --- root
contributes three dir entries --- so with the per-entry poll deleted
the directory finishes and the per-DIRECTORY poll catches at seen ==
44, under the old bound of 60. The bound is now 40 against an expected
exactly-35 (3 + one 32-entry poll stride), and the entry-poll-only
bite goes red at 44. Verified both ways.
The retirement helper observed a REQUEST, not settlement: it returned
as soon as an active row showed cancel_requested, which a worker that
ignored the token and completed successfully would satisfy. It now
waits for a completed row with status == "cancelled", making the
lane's "settles cancelled" claim true at the witness, not just at the
Rust layer.
The CI red: d3_pump(1600) between the mid-walk join and the late.bbb
write assumed the held walk would complete within 1.6 s. On a 3-thread
CI pool, 8 sleeps of 1200 ms drain in ~3.6 s of waves, so the file
landed before the held walk even STARTED and folded into the joiner's
baseline --- exactly the fold the test exists to assert for mid.bbb,
applied to the wrong file. Deterministic on every 2-4-core runner,
invisible on 16 cores. The drain is now an observable condition ---
at least one post-join walk completed and none active --- with the
saturation sleeps at 800 ms, and the three saturation tests plus the
whole eighteen-test family re-run green under taskset -c 0-3, the CI
pool shape reproduced locally.
A fixed-duration pump against pool-dependent timing is a core-count
assumption in disguise; the lane records it as such.
Superseded round-one text in the lane (the fallback "unreachability"
claim round two disproved) is corrected in place.
One gate run also hit the live attach-retry BrokenPipe row --- fourth
occurrence, all three required fragments verified against the durable
sweep log, recorded in docs/ci-red-signatures.md. This lane touches no
pmacs-gpu code, no wire, and no protocol; the same sweep passed twice
earlier the same day on materially the same tree. The retirement bar
(mechanism, not rate) is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements docs/lsp-file-watch-d3-framing.md revision 4, approved
2026-08-11 with the four rulings adopted as proposed: the honest bar
(absent at idle, one attributable job per concurrently due group), no
exclusions by default, server root_uri -> cwd -> attachment fallback,
and constants rather than config keys.
pmacs.fs.walk_tree: the whole recursive tree as ONE cancellable job
(JobKind::FsWalkTree, reply reuses ReplyKind::ReadDir --- identical
payload shape, so the Lua boundary needs no second conversion). Names
are base-relative; symlinks recorded, never traversed; an unreadable
subdirectory skips its subtree (scan_tree's pcall behaviour); only the
root failing to open fails the walk; the cancel token is polled once
per directory. Eight Rust unit tests, including flat-directory entry
parity with read_dir_blocking and the two review-round cancellation
cases (empty-tree pre-cancel; mid-walk via the cfg(test) entry hook).
The watcher itself is rewritten as the framed group scheduler. No
sleeps anywhere: one process.after-tick subscription (installed once
and guarded --- pmacs.hook.remove does not exist) drives every
(server, base) group's deadline off monotonic_ms, autosave's Q#AS2
idiom. The old design held one pool thread per sleeping watcher and
allocated 1 sleep + D read_dir jobs per watcher per tick --- 1,326
per tick for rust-analyzer's six watchers on this 220-directory
checkout. At idle there is now NO running job, which is also the
strongest witness in the suite: activity_summary settles to None, and
that assertion is unwritable under the old design.
The scheduler is the framing's state machine, all three review rounds
included: single-flight per group with generation-checked completions;
deadlines advanced from completion; the round-3 three-arm completion
partition (success / stale-or-retired / live non-success, with the
failure latch and quiet cancellation); joins wake the group, queue
exactly one follow-up mid-walk, and never reset the backoff curve;
per-watcher baselines --- the first snapshot whose WALK STARTED after
the join; membership captured at scan start; per-member cancellation
recheck at emit through the preserved _after_scan_for_tests seam;
backoff 250ms x2 to a 4s cap, reset by any emitted change; retirement
cancels the in-flight walk cooperatively.
Verification: eighteen acceptance tests. The six #234 tests are
byte-unchanged and green. Ten witnesses cover the framing's plan (the
review rounds added the fallback-determinism and root-boundary pair,
making twelve):
idle absence (and never a sleep purpose), one walk job per scan on a
twelve-directory fixture, join-wakes plus the registration epoch,
queued baseline for a mid-walk join (driven by saturating the worker
pool so the walk genuinely queues), single-flight under a withheld
completion pump, retirement and rebaseline through the fake's
unregister/re-register triggers, live cancel via pmacs._async._cancel
on the queued job, live failure with the once-per-error latch and the
preserved-snapshot recovery (DELETED for the pre-failure file is only
derivable from the retained snapshot), backoff shape from seam
timestamps, and the configured-root base.
Every witness was mutation-tested. Two findings from the bites:
- Retirement is DOUBLE-ENFORCED (unregister path and post-scan sweep)
and biting either copy alone is masked by the other; only biting
both goes red. Kept deliberately: the sweep covers seam-cancelled
members, the unregister path covers idle groups whose next deadline
is seconds away.
- The first idle probe was VACUOUS: it read pmacs.async instead of
pmacs._async, errored, and the unwrap_or_default made every sample
read as "absent". The probe now expects rather than defaults, so a
broken probe is a red test, not a green lie.
One environmental fact, recorded in the lane: an empty stray /tmp/.git
(since removed) made project detection root every markerless tempdir
fixture at /tmp, which under Q#D3-3 the watcher then faithfully
watched. A markerless-fixture red that looks like a watcher bug may be
an ancestor marker.
A pre-commit review round found four blockers, all fixed here:
- walk_tree checked cancellation only inside its entry loops, which an
EMPTY tree never enters --- a pre-cancelled queued walk returned an
empty SUCCESS, which the success arm would commit and diff into a
deletion storm. Cancellation is now checked before opening and
before returning, cancellation outranks a missing-root error, and a
unit test pins both.
- The neither-root-nor-cwd attachment fallback was still pairs-order
nondeterministic --- the exact accident D3 set out to remove, behind
a comment claiming otherwise. It now takes the lexicographically
smallest attachment directory. Verified at the spawn sites: every
server spawned with an attached file gets cwd = root, so the arm is
defensive and unreachable through production spawning --- which is
also why it carries no through-the-server witness.
- A base at the filesystem root joined as //path (and file:////path in
URIs). Both join sites now go through join_under, the root-aware
idiom dired's handler already uses, and the dir-of capture for a
root-level file ("" from the match) normalizes to "/".
- The walk-count and scan-times probes defaulted on error, so two
broken probes could compare equal and pass the retirement witness.
Every probe now expects --- a broken probe is a red test, the same
correction the vacuous idle probe forced.
A second pre-commit round found three more, all fixed here:
- Mid-walk cancellation was UNWITNESSED: both Rust cancel tests
pre-cancelled and the acceptance test cancelled a queued walk, so
deleting the internal polls left every test green. A cfg(test)
entry hook now flips the token at an exact entry boundary and the
witness asserts the walk stopped NEAR it (bound on entries
processed), which is what discriminates the polls from the
entry/exit checks. The retirement witness now holds a walk in
flight across the unregister and asserts the job settles cancelled.
- The "unreachable fallback" claim was WRONG: pmacs.lsp.spawn may
omit both cwd and root_uri, and ensure_server adopts such a live
server for markerless files (root_uri and key_uri both nil). The
lexicographic-minimum fallback now has a through-the-server
witness: five sibling directories, the minimum opened last ---
five, because with two the build's hash order coincided with the
lexicographic answer and the first-pairs bite survived.
- The root-boundary joins gained a witness through exported
production functions (the _deliver_status pattern): the matcher and
URI builder driven at base "/", where reverting either join_under
call makes the anchored glob refuse //hit and the URI grow a fourth
slash. No fixture can walk / for real.
Verification totals after both rounds: eight walk_tree unit tests,
eighteen acceptance tests (six byte-unchanged, twelve witnesses), all
mutation-verified.
No wire change, no PROTOCOL_VERSION bump; walk_tree is an fs binding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review of the #233 implementation found two correctness defects. Both
are cases where the FIRST fix for #233 was itself wrong, which is worth
naming: this lane repaired absolute globs and, in the same change, broke
a case that had worked since May.
P1 --- `resolve_watcher` returned "absolute" for EVERY string, so the
form was carried but derived from the union arm rather than from the
pattern. A bare `*.txt` is a valid relative pattern (LSP 3.17 defines
`Pattern` relative to a base path; VS Code treats string watchers as
applying across workspace folders), and classifying it absolute matched
it against `<base>/foo.txt`, which `^[^/]*%.txt$` can never match. A
leading `/` is what makes a pattern absolute. The `filewatchflat` test
could not catch this: it sends the RelativePattern OBJECT form, so it
constrains the object arm, never the string arm the regression lived in
--- the framing's own F1 finding, repeating inside the lane that named
it.
P2 --- `scan_tree` awaits `read_dir` once per directory, so the watcher
coroutine sits suspended for most of a tick with `_sleep` already
cleared. A cancel arriving there sets `cancelled` and has no sleep to
interrupt, and the resumed scan ran on to `did_change_watched_files`:
one stale batch under the superseded pattern, which is a wrong-pattern
notification the server acts on. Cancellation and liveness are rechecked
after the scan.
Both tests are mutation-checked and each bite fails only its own defect.
Reverting P1 fails the bare-string test alone --- the absolute test
still passes, so the two readings are independent. Deleting P2's recheck
reproduces the defect verbatim: `.received = "1 file:///…/foo.txt"`,
a batch from a watcher that was already cancelled.
P2's witness needs a seam. The race is a cancel landing during one of
the scan's suspensions, which no arrangement of real timing produces on
demand --- the same situation, and the same device, as `git.lua`'s
`_deliver_status`. `pmacs.lsp._after_scan_for_tests` is nil in
production and is handed the SCAN RESULT deliberately: a test that
cancels on any other scan passes with the recheck deleted, because the
loop would break at the post-sleep check and emit nothing anyway.
Gates: 9/9 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Implements D1 and D2 from docs/lsp-file-watcher-framing.md (revision 1,
approved 2026-08-10). The user ruled that the walking survives this
lane; D3 gets its own framing.
D1. resolve_watcher now returns (base, pattern, form) and the watch
record carries the form. Per LSP, a plain-string glob matches the
file's ABSOLUTE path while a RelativePattern's pattern is relative to
its base --- and resolve_watcher discarded the distinction, so every
downstream consumer matched relatively. Real servers send absolute
globs: rust-analyzer was never told about any file change (all six of
its globs absolute), and gopls saw go.mod but never a .go edit. The
match subject is chosen in start_file_watcher --- form "absolute"
matches base .. "/" .. rel, form "relative" matches rel unchanged.
scan_tree still walks in relative terms; only the string handed to the
matcher changes.
D2. register_file_watchers now cancels the outgoing record list before
file_watchers[skey][reg.id] = recs drops the only reference to it. The
cancel loop is factored into cancel_watch_records, shared with
unregister_file_watchers, so the two paths cannot diverge.
rust-analyzer registers the same id twice with no unregister between
--- previously 12 concurrent pollers, 6 permanently uncancellable.
Verification, per the framing's plan. Three new fake-LSP modes and
tests beside m4_24, each mutation-tested against the defect it names:
- filewatchabs registers a plain-string absolute glob whose relative
reading matches nothing. Red before D1 (reverting the match subject
fails exactly this test), green after.
- filewatchflat registers a RelativePattern without a leading **/
(*.txt at the base) --- F2's guard. Matching every form absolutely
fails exactly this test, so the obvious wrong fix cannot land green.
It also pins that a base-level pattern does not match into
subdirectories.
- filewatchrereg registers the same id twice (rust-analyzer's shape).
The witness is observable polling, not table shape: f.old exists on
disk before either .new event lands, so a leaked watcher at the same
250ms cadence reports it before the second positive. Reverting D2
fails exactly this test, the leaked .old event visible in .received.
m4_24 stayed green under all three mutations --- the framing's F1
finding (the existing test is insensitive to D1 in both directions),
confirmed rather than assumed. It is kept unchanged.
The framing doc records the approval and the answered ruling; the
active-work lane moves to IMPLEMENTED with the verification results.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merged rather than rebased, for the reason #231 was: nineteen commits
replayed against a ledger four lanes had rewritten is nineteen chances
to lose a lane entry, and the reviewed commits keep their SHAs. One
file conflicted.
The conflict was diff3-style with an EMPTY base --- both sides added
lanes at the same offset --- so it resolved by keeping both, this
branch's #227 lane ahead of main's. Worth noting because an empty base
section is exactly what produced the stray `|||||||` markers found
committed in this file earlier today: the base half of a diff3 conflict
reads as content if it is resolved by hand in a hurry.
This unblocks the lane. `pmacs.window.capture_destination` now exists
on the branch (`src/lua_bindings/window_panel.rs:638`), which is what
#227's review found missing and what #231 was split out to provide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Merged rather than rebased. Eighteen commits replayed against a ledger
that three other lanes had rewritten meant eighteen conflict
resolutions in `docs/active-work.md`, each one a chance to lose a lane
entry; merging resolves it once, against the state that actually ships,
and leaves the reviewed commits' SHAs intact. Only one file conflicted.
`docs/ci-red-signatures.md` auto-merged **without a conflict** — the
same silent path that produced duplicate U4/U5 ids when #232 rebased.
Verified by hand afterwards: ids U1-U8 are disjoint. They are out of
numeric order (U6/U7 sit ahead of U4/U5) and are left that way rather
than moved, since the note at the U6 row explains the history and
relocating sixty lines inside a merge commit hides real changes.
Three leftover conflict markers were sitting in `docs/active-work.md`
on `main`, committed by an earlier lane's resolution. `git diff --check`
flags them — but only for a working-tree diff, which is why the gate's
`diff-check` step never saw them and they survived several merges.
Removed here.
The U4 row is corrected on evidence this lane produced:
- **Flavour was wrong as a matching key.** The row was filed from
#229's `lua54` red and put the flavour in the key; #231 reddened the
identical selector with the identical three fragments twice on
`luajit`. Matching as filed would have missed both.
- **A fourth sighting was a deliberate bite, not an occurrence** — the
defect reintroduced on purpose during the test's own development. It
is recorded for what it proves instead: the genuine defect and these
CI reds are signature-indistinguishable, same message class and same
full-timeout duration.
- **The control experiment is written down with its own bounds** — five
green base observations against 0/2, 4.8% under an equal-rate model,
and the two facts that bound it: attempt 5 reddened a different
selector, and the branch side was never resampled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
`required_purpose`'s invalid-UTF-8 refusal told the caller that their
process purpose "is displayed to the user in *workers* and in the
modeline". Neither is a process surface. Stage 1 deliberately keeps
processes out of `*workers*` — which lists async JOBS — and out of the
statusline activity indicator; a process's purpose is exposed through
`pmacs.process.list` and nowhere else, and joining the two planes is
Stage 2's work (framing §3, Q#W-4).
The refusal is correct and stays: a purpose that cannot be displayed
anywhere should still be refused, and nothing spawns either way. What
was wrong is the reason given to the user, which pointed them at two
places their process will never appear. A diagnostic that misdescribes
the system is worse than a terse one, because it sends the reader
looking in the wrong place.
The job-side twin diverges rather than converging. `_push_dispatch_name`
refuses a non-UTF-8 handler name for the same reason, and there
`*workers*` and the modeline are the RIGHT answer — the name is composed
into every job's purpose and a job renders in both. It said only "as
part of every job's purpose", which names no surface at all, so it now
names the two it reaches. The two messages must not collapse into one
sentence: whichever wording won would be wrong on the other side.
Verification. `the_two_utf8_refusals_each_name_the_surface_their_own_text_reaches`
asserts both directions, positive AND negative — the process message
contains `pmacs.process.list` and NOT `*workers*`/`modeline`, the job
message contains both of those and NOT `pmacs.process.list`. The
negative halves are the anti-collapse guard; without them a later
"unify the wording" edit reintroduces exactly one wrong sentence and
passes every other test in the file. The existing row-table assertion in
`spawning_without_a_real_purpose_is_refused_and_starts_nothing` now runs
as far as the surface name too, so the same edit breaks two tests.
Three mutation checks, each red on its own claim: restoring the old
process wording fails both content assertions; collapsing the job
message onto the process wording fails only the new test (which is the
point — the old job test asserted the prefix alone and could not see
it); restoring the job message's original vague wording fails it too.
The doc comments were fixed with the literals. `required_purpose`'s
rustdoc now states which surface its message names and why it names
neither of the other two, and the `_push_dispatch_name` comment states
the converse. A corrected string whose doc comment still argues the
other way is one refactor from reverting itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Review round 2, findings P2a and P2b, plus P3's stale recovery
summaries. Three defects, and the fix is deliberately different in each
place because the constraint is.
## P2a — invalid UTF-8 bypassed the `purpose` diagnostic
`required_purpose` read the field with `value.to_str()?`. Lua strings
are BYTE strings, so `purpose = string.char(255)` is a value a caller
can write, and `?` surfaced mlua's generic conversion error BEFORE this
lane's own diagnostic was ever constructed: the caller was told neither
the field nor the rule.
**This is the third time this project has hit the class** — an unowned
Lua string converted with `?` ahead of the owned message; the
destination-capture lane corrected the same shape two rounds ago. It
refused before spawning and nothing leaked, so the defect was the
message, not the outcome. The conversion failure is now mapped onto
this function's own message, and the new acceptance row asserts on
message CONTENT so retyping the read as a bare `?` breaks the test
rather than silently degrading the error.
Auditing the rest of the lane's diff for the same class turned up
exactly one more: `_push_dispatch_name` took `name: String`, so a
registered handler name that was not valid UTF-8 failed at first
dispatch with mlua's generic message. It now takes `mlua::String` and
maps that failure onto an owned diagnostic naming the argument and the
rule. Those are the only two Lua-string reads this lane added; every
other binding it adds takes `()`.
## P2b — no safe display-text boundary. Two halves, two different fixes
### Handler names are refused at the source
`pmacs.workers.register` type-checked its name and nothing more, which
was defensible while the name died inside `dispatch`. It no longer dies
there: the ambient carries it into every job the handler allocates and
composes it into `purpose`, which `*workers*` and the modeline both
render. So it now gets `purpose`'s meaningful-value standard —
non-empty, not whitespace-only — plus control characters, which have no
legitimate place in a registered identifier.
### Purposes are ESCAPED at presentation, not rejected at the registry
A purpose may legitimately contain a newline: a filesystem path can, and
`pmacs-magit`'s spawn purpose is a whole argv. **This is the shape of
the `#228` decision, and it is consistent with it** — the one-line
constraint belongs to the surface that has it, not to the registry that
does not. There, `Command.description` stays free-form and the two
single-row consumers clip with `description_first_line`. Here the
equivalent is escaping rather than clipping, because a purpose's later
words are load-bearing: an argv's second word says which file, and a
clip would drop it silently.
`purpose_for_one_row` states the property it exists for: **a row must
not be able to forge another row.** It escapes `\n`, `\r`, `\t` and the
rest of the Unicode `Cc` class (which covers ESC, so a purpose cannot
open a terminal escape sequence either), borrows unchanged when there is
nothing to escape — making byte-identity structural rather than
asserted — and deliberately does NOT escape backslashes: no number of
them produces a second row, and doubling them would cost byte-identity
for ordinary text.
Two surfaces call it: the `*workers*` rows, and `ActivitySummary`, which
exists for one consumer that has exactly one row.
`pmacs.workers.snapshot()` is this lane's `describe-command` and stays
raw, which is what makes this a rendering decision rather than data
loss — asserted, not assumed.
## P3 — two stale recovery summaries
`docs/worker-identity-framing.md` still said "Implementation may
proceed"; it is implemented. `docs/active-work.md` still said Stage 1
takes the "first two" of owner/purpose/parent — `owner` was REMOVED in
revision 2, so it takes one of the three, and the claim the whole
`owner` argument overturned was still standing in the volatile state of
record. Both fixed section-locally.
## Verification
`tests/worker_identity_acceptance.rs`, 18 -> 24 tests:
* invalid-UTF-8 purpose refused by THIS lane's message, asserted on
content, alongside the absent / empty / whitespace / wrong-type /
metatable rows;
* a whitespace-only handler name and a control-character one are each
refused AT `register`, asserted on the error and on the handler not
being installed (dispatch reports `unknown handler`);
* a non-UTF-8 handler name is refused before the handler runs, with the
dispatch-name stack left empty;
* a purpose containing a newline renders as ONE row in `*workers*` and
as one line in the modeline — through the real rendering path, the
latter through a painted frame as well as the evaluator;
* **a purpose crafted to look like a row boundary does not produce a
second row** — asserted by counting rows, with the escaped text
asserted present so a renderer that dropped the purpose entirely could
not pass;
* a purpose with no control characters is byte-identical on both
surfaces, fixtured with a literal backslash, a literal `\v`, quotes
and a non-ASCII character.
Mutation-checked, seven guards, each failing its own test and no other:
the purpose UTF-8 diagnostic; the `_push_dispatch_name` one; the
register whitespace guard; the register control-character guard; the
`*workers*` call site; the `ActivitySummary` call site; and
`purpose_for_one_row` itself neutered to the identity, which fails both
surfaces' tests and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Review round 1 on worker identity Stage 1. The lane shipped `purpose` as
a required field on `ProcessSpec` but made it OPTIONAL at the
`pmacs.process.spawn` Lua surface, defaulting to `label`.
**That preserved compatibility and delivered nothing.** `COHERENCE.md`
§9's complaint about `ProcessSpec` is precisely that `label` is
"caller-supplied, unvalidated convention" — so a purpose defaulting to
the label hands every existing caller back the exact convention this lane
exists to replace. The approved framing said required; this makes it
required where callers actually are.
The two fields answer different questions and neither substitutes for the
other. `label` IDENTIFIES — `lsp:rust-analyzer`, a terminal's buffer
name — so two processes running the same binary can be told apart.
`purpose` DESCRIBES: it answers "what is happening", which is what §3's
promise of visible asynchronous work is about, and which a label chosen
for uniqueness routinely does not answer.
**The refusal covers five shapes, not one.** Absent; empty;
whitespace-only; wrong type; and metatable-provided. The middle two
matter because they satisfy the type and defeat the point exactly as
copying the label across would — R42 already rejects whitespace-only
`description`s in the config registry for the same reason, and a required
field that accepts `""` is not required in any sense a reader benefits
from. The read is RAW, matching the posture `stdin` and `group` already
document in the same function: a spec table is plain data, so `__index`
cannot smuggle a purpose in.
Every refusal also asserts **the process list is unchanged**. A
validation that rejects after spawning has already done the thing it was
rejecting.
**This is a BREAKING CHANGE to a public Lua API, taken deliberately and
now rather than later.** Weighed and reported rather than decided
silently: §10 grades extension trust "missing (one class)" and P7 package
lifecycle has not started, so the third-party population calling this
binding is ~zero and the cost of the change only rises from here. Checked
for a reason that would be wrong and found none — `pmacs.process.spawn`
has no API-reference documentation and no stability promise anywhere in
`docs/`; the guide's only mentions are an audit-rule classification and a
pointer to the bundled REPL, and its semver language governs *packages'*
own versioning, not pmacs's Lua surface. `lua_to_spec` has exactly one
caller, so the blast radius is this one binding.
Eleven executable call sites updated, each with a real description rather
than the label copied across — copying it would satisfy the type and
defeat the point as surely as the default did:
builtin/packages/repl/init.lua "interactive <interpreter> session"
builtin/runtime/compile.lua "compiling: <cmdline>"
builtin/runtime/lean.lua "checking the Lean toolchain version…"
tests/fixtures/pmacs-magit/status.lua the full argv, not just the
subcommand the label carries — "git
log" and "git log --oneline -20" are
one label and different work
tests/compile_mode_acceptance.rs (4), tests/m4_acceptance.rs (1),
tests/worker_identity_acceptance.rs (2)
`lean.lua`'s site is the clearest case for the field: its comment said
the label was where "a user wondering why their editor touched `lake`
finds an owner" — one string doing identity AND explanation, which is the
conflation being undone. The label stays a key; the purpose is now the
sentence.
Two references are deliberately NOT updated: `src/audit/mod.rs` and
`tests/m7_9_acceptance.rs` contain `pmacs.process.spawn("ls")` as **audit
fixture source text**. It is lexed by the audit engine, never executed,
and editing it would change what those rule tests scan.
`required_purpose` is extracted rather than inlined because inlining it
pushed `lua_to_spec` past the 100-line clippy bound — the validation has
its own rules and its own rationale, so it gets its own function instead
of an `#[allow]`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
`COHERENCE.md` §9 grades the worker model "mechanism without identity",
and §0 names step 11 (background-work ownership) as one of the two
remaining thin ends of the golden journey. The mechanism half is solid —
cancellation, supersession, streaming, frame-aware draining, `*workers*`.
The identity half was absent: `PendingJob` carried no description of what
it was doing, `pmacs.workers.dispatch` discarded the registered handler
name three layers above anything that takes one, and §9's "no progress
indicator exists anywhere" was checkable and true.
Framing: `docs/worker-identity-framing.md` (revision 4, approved).
What lands:
**A required `purpose`, on the job and on the process.** Non-optional,
with no `Default`, so the compiler — not a test — is what proves every
dispatcher supplied one. `allocate` / `allocate_with_resource` collapse
into ONE private `JobSpec`-taking funnel (Q#W-1): the two-function split
existed only because one prior lane needed one extra parameter, and a
second lane doing the same produces `allocate_with_resource_and_identity`.
`register_external` gains a `purpose` parameter rather than deriving one,
because its `JobKind` is `McpRequest`/`LspRequest` for every method — a
category, not a description.
**A dispatch-name ambient (Q#W-2), read at that same single funnel.** The
capture point is Rust, not the Lua wrapper layer, because a handler
reaching straight for `pmacs._async._dispatch_*` bypasses the wrappers
entirely — and those are precisely the callers attribution exists for.
Seven rules; the ones that decide whether it is honest:
- **Rule 1 — the extent is NON-YIELDABLE, and that is ENFORCED.** Both
supported yield APIs refuse inside it, modelled on the `commit_to`
refusal already in `async.lua`. The guards reject BEFORE parking and
reject UNCONDITIONALLY: one placed after `_is_complete` would fire only
when a yield really occurred, passing under test and failing
intermittently in production.
- **A raw `coroutine.yield` is NOT covered, and nothing here claims it
is.** R46 is a convention, and the scheduler inspects the yielded value
only after `coroutine.resume` returns — by which point the coroutine has
already suspended — so no refusal sited in a yield helper is ever
consulted. The residual is recorded in the framing §2 and in the
suite's module docs rather than papered over with a test that would
imply coverage this design lacks.
- **Rule 5 — unwind-safe.** A raising handler still pops. A version that
did not would let one failure poison every later dispatch in the session
with a stale name: the feature would stop failing loudly and start lying
silently. The bracketing also has to preserve the tail call it replaced:
`dispatch` was `return handler(args, opts)` and propagated EVERY return
value, so the pop/rethrow runs behind a varargs boundary rather than a
`local ok, result = pcall(...)` that would silently truncate a
multi-value handler. Varargs rather than `table.pack`, because that is
Lua 5.2 surface and LuaJIT is this project's default backend.
- **Rule 6 — compose, do not replace.** `"<name>: <purpose>"`, because
letting the dispatcher's purpose win loses the third party again and
letting the name win discards the only description of the actual work.
**A statusline activity indicator** — the fourth `pmacs.statusline.register`
adopter, after `mode`, `terminal` and `lsp`. A count plus the OLDEST
in-flight job's purpose ("busiest" is not a defined quantity; jobs carry
no cost estimate), and **absent entirely** when idle rather than a
zero-width segment that costs modeline width forever to say nothing is
happening. Gated by one setting, `ui.activity-indicator` (boolean, default
true, Q#W-6) — a permanently-visible modeline element is a preference
someone genuinely holds on day one. No setting for purpose capture
itself: that is substrate.
**NO WIRE CHANGE.** The indicator rides the existing `StatuslineSegments`
vector, so a fourth provider adds an element, not a variant.
`PROTOCOL_VERSION` and `ADVERTISED_PROTOCOL_VERSION` are untouched — which
is the property that lets this run beside the two lanes holding the bump
slot.
**Q#W-7 — a pre-existing defect, repaired here, and NOT one anybody has
observed.** `Handle:await()` refuses inside `pmacs.window.commit_to`
precisely so a coroutine cannot park with the frontend scope pushed
(Journey Stage 1a, Q#JR14b). But `pmacs.async.yield_to_next_tick()` also
yields, is public, and carried no such refusal — so that invariant had a
second entrance, and a coroutine could produce exactly the misrouting the
`await` guard exists to prevent. It gains both refusals here: the same
supported yield helper, the same invariant, the same edit family, so
splitting it would have preserved a known hole without reducing
integration risk.
**Reachability by a real caller is UNPROVEN.** This was found by reading
the guard family while scouting rule 1, not by reproducing a fault. No
production caller is known to yield through that door inside a commit,
and the test pins the guard rather than reproducing a user-visible bug.
Nobody should later cite this commit as evidence the bug was observed in
the wild. Its witness is a PAIR, like rule 1's: the refusal fires **and**
the commit scope is restored afterwards — a guard that raises while
leaving the scope pushed converts a silent fault into a loud one and
fixes neither.
`journey_acceptance` carries the established `commit_to` pins —
forged-destination refusal, scope-and-restore on normal return and on
raise, the await refusal, delivery to the requesting frontend. It passes
**untouched**, which is what says this closed a gap in Journey Stage 1a's
semantics rather than altering them.
What is deliberately NOT here, and why it is worth saying:
- **No `owner`, in any spelling** — not `origin`, not `subsystem` (§3).
Populated from static per-subsystem constants it would be an origin,
not an owner, and would confidently misattribute third-party work to a
builtin at exactly the point §9 wants attribution. A field that asserts
a falsehood is worse than an absent one. The slot stays empty until P3
can fill it with a real package signal.
- **No `parent`** (Q#W-5). An unpopulated field renders as `None`
everywhere and reads as "this job has no parent" rather than "this
system does not track parents". Stage 3 builds the lifetime model and
the field together.
Consequences worth recording:
- `ProcessSpec::new` takes a third argument. The 40-odd call sites are
almost all tests; the three production ones (LSP, MCP, terminal) supply
real descriptions. `pmacs.process.spawn`'s Lua surface keeps `purpose`
OPTIONAL, falling back to the label — requiring it there would break
every existing caller for no coverage the compiler is not already
providing, and a caller's own label is not a fabrication.
- `pmacs.process.list` gains a `purpose` KEY on each row and enumerates
exactly the same processes (Q#W-4). Terminal PTYs stay hidden: three
acceptance suites use `#pmacs.process.list()` as a leak baseline, and
widening the accessor would inflate all three. Stage 2's unified view
owns that decision.
- `statusline_segments_acceptance`'s builtin-provider inventory grows to
`["activity", "mode", "terminal", "lsp"]`. That assertion exists to
grow when a builtin provider is added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Revision 8 refuses, inside a "panel" commit_to, the mutations that
would make its relaxed preflight wrong. A nested commit_to REPLACED
the enclosing contract with its own and restored it afterwards, so
the outer restriction went out of force for the whole inner body:
commit_to(outer, function() -- "panel", relaxed preflight
commit_to(inner, function() -- "document", MASKS the outer
set_params(panel(), { dedicated = true }) -- ...and succeeds
end)
display(result, { side = "bottom" }) -- ...which now FALLS BACK
end, "panel")
Every step is legal on its own, and the outer commit then overwrote
a newer document buffer --- the P1a failure the lane exists to
remove, reached through one extra call.
What this invalidated, precisely: NOT the enumeration of dedication
write sites. Every site in it is real and still guarded. What was
wrong was the claim that the guard was in force for the whole outer
body. So the enumeration is inherited and qualified, not redone.
Contracts now COMPOSE rather than replace. The core holds a stack;
ScopedFrontendGuard pushes on entry and truncates back to its own
depth on every exit path; panel_commit_dedication_refusal consults
every contract in force rather than the innermost. The strictest
active restriction wins. Matching stays per frontend --- a nested
commit for a different frontend may dedicate its own side slot,
which cannot change where this frontend's side request lands.
Nesting itself is NOT forbidden, which was the other candidate fix.
It closes the hole by prohibiting a construction no rule objects to:
commit_to is public Lua API for saying where a continuation's result
belongs, and a body committing to a second destination (a diff
beside a status panel) is where #227's adoption is heading. Only the
restriction needed preserving. Detecting the dedication when the
outer commit resumed was not available either --- that is a late
refusal, which is what revision 7 was rejected for.
Two pins, and they are a pair rather than one test written twice:
* a_nested_commit_cannot_mask_an_outer_panel_restriction drives the
same four write-site rows through a nested, entirely valid
"document" commit, and asserts the attempt is refused, the slot is
still undedicated, and the outer commit's destination is intact.
* an_ordinary_nested_commit_still_runs_and_restores_the_outer_restriction
pins that nesting without dedication is accepted, that the
enclosing restriction is back in force once the nested commit
returns, and that outside every commit dedication is ordinary
again.
Mutation-checked: restoring the guard to the innermost contract
(.last(), exactly revision 8's swapped slot) fails only the first of
those. The other 13 pins, journey_acceptance (31), dired_acceptance
(47) and cargo test --lib (1920) all stay green. The ordinary-nesting
pin deliberately survives it --- it exists to fail the other
candidate fix.
Also sweeps the comments left by revision 7, which revision 8
superseded: no fallback_commit_refusal symbol remains, but six doc
sites still described placement-boundary enforcement as the
guarantee (ViewDestination, CommitProfile::Panel, CommitContract,
capture_view_destination, commit_destination_refusal,
panel_placement_can_fall_back), plus two comment blocks in the
commit_to binding and one stale mutation note in the acceptance
suite. Net rustdoc warnings down three.
Framing to revision 9; the active-work lane entry updated in place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Revision 8 of `docs/destination-capture-framing.md`, replacing the
revision-7 design at `ca72461`, plus the invalid-UTF-8 profile hole.
The framing now carries §3's enumeration, performed.
THE BLOCKER, unchanged. The `"panel"` commit profile skips preflight
checks 2-4 on the claim that a panel result never touches a document
window. Panel placement FALLS BACK into an ordinary document window
when the frontend is not `panel_capable` or its one side slot is
dedicated elsewhere, and installs the result there --- so a `"panel"`
commit could replace a NEWER document with every stale-intent guard
skipped.
TWO REJECTED SHAPES, kept in the framing as the record of why not
those. Revision 6 predicted the fallback at preflight and argued the
body could not change it; false, because refusing `await` stops another
COROUTINE interleaving, not the body's own synchronous statements.
Revision 7 (`ca72461`) moved enforcement to the placement boundary;
that breaks the invariant `commit_to` exists for --- `docs/agent-handoff.md`
requires it to preflight BEFORE the callback, because a body creates
buffers, handles and paint long before it asks to display anything, so
"validating at display time is four mutations too late". A refusal
arriving after all of that is a partial commit with an error return.
REVISION 8 DOES NEITHER. The preflight stays exactly where it was, and
the mutations that would invalidate it are REFUSED AT THE ATTEMPT ---
the same shape as `Handle:await` being refused inside a commit scope,
for the identical reason: something that would invalidate the scope's
guarantee is rejected outright rather than predicted around or caught
late. With them refused, the fallback never comes into existence.
THE ENUMERATION, PERFORMED --- this is the load-bearing part, and it is
closed for a structural reason rather than because inspection ran out
of ideas. Full working in the framing §3.
`resolve_placement` reaches `Ordinary` from a side request through
exactly two branches, so only two pieces of state are levers at all:
`panel_capable`, and the one side window's `dedicated`.
`panel_capable` is UNREACHABLE from a body: written only where a
`FrontendView` is constructed, and nothing in `src/lua_bindings/`
constructs, registers or unregisters one --- `register_frontend_view`
has callers only in `daemon.rs` and core unit tests.
`dedicated` has eight writes. Five are reachable: `apply_placement`'s
`Side` created, replacing and non-replacing arms, and `set_params`. Two
`Ordinary` arms are harmless --- every `Ordinary` target is filtered
`!is_side`, and one only ever clears the flag. One is a unit test.
Closing the side window is NOT a route, checked rather than assumed:
with no side leaf `side_window_for` returns `None` and placement
CREATES a fresh panel instead of falling back. `panel_hidden` is not
consulted by placement, and `params.side` is unreachable.
`quit_window`'s `QuitAction::Restore { dedicated: true }` is
UNREACHABLE, and this was the surprise --- it looked like a route with
no `dedicated` argument at the call site at all. `Restore` is stored
only on a REPLACING side placement, and a dedicated slot can never be
the target of one: a side request with a different buffer falls through
to `Ordinary`, and an exact-target request is refused by
`window_accepts_buffer`. Guarded anyway, labelled defensive, because
its unreachability is emergent from two rules in another function.
GUARDS SITED WHERE THE PROPERTY CONVERGES. All three `Side` arms are
reached through `apply_placement`, which has EXACTLY ONE caller --- so
one guard in `display_buffer` covers every request-driven dedication,
including spellings that do not exist yet. `set_params` is a genuinely
separate write and is guarded separately; dedication does NOT converge
before the field itself, and that is stated rather than papered over.
`Window::params.dedicated` is a public field, so the compiler does not
enforce the funnel --- the acceptance rows are what would catch a new
direct writer.
WHAT IS DELIBERATELY NOT REFUSED. The document profile is untouched:
constraining its body would newly refuse dired's own documented panel
path, a preservation-suite stop signal. Dedicating a DOCUMENT window is
still allowed, since it cannot change which of panel-or-document a side
request resolves to. And falling back is still allowed --- a frontend
that cannot render a panel degrades gracefully exactly as today,
because this refuses the mutation that MANUFACTURES a fallback, never
the fallback itself.
THE SECOND HOLE. `commit_profile` did `name.to_str()?`, but Lua strings
are BYTE strings, so `string.char(255)` hit mlua's generic UTF-8 error
before `BAD_COMMIT_PROFILE` was constructed --- the same reachability
class as the `Option<String>` defect revision 5 fixed, one layer down.
Bytes now, with the row asserting on message content.
TESTS: 12 pins. The inside-the-body test is ONE ROW PER REACHABLE WRITE
SITE, not per call spelling, because one spelling reaches three
different writes: `set_params`, and `display{side, dedicated}` in each
of the created, replacing and non-replacing arms. Each asserts the
three things revision 8 requires --- the dedication call is refused, the
slot is still undedicated afterwards, and nothing partial was installed
(no `*result*` buffer, panel unchanged, document unchanged).
Mutation-checked per guard: deleting the `display_buffer` guard fails
all three display rows, verified INDIVIDUALLY by rotating each to the
front so the first failure cannot mask the rest; deleting the
`set_params` guard fails only that row.
THREE FRAMING CORRECTIONS ride along, all of them cases of the document
teaching something it later argues against. Section 3 stated the
disproved premise unconditionally --- "the panel case would inherit a
check about a window it never touches" --- a hundred lines before
correcting it, so a reader met the wrong claim first; it is now
qualified at the point of the claim, and section 2 carried the same
unconditional form one section earlier ("it lands in the bottom panel")
and now says it REQUESTS one. The handoff citation was written "section
748" twice when it is LINE 748, and this document's authority is that
its citations can be followed. And the "not asserted exhaustive" hedge
on the route list is retired: the enumeration is closed structurally,
because `resolve_placement` reaches `Ordinary` from a side request
through exactly two branches.
`journey_acceptance` (47) and `dired_acceptance` (31) pass UNCHANGED.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Revision 7 of `docs/destination-capture-framing.md`, closing the
correctness blocker review found in `0efc8c0` and the smaller
reachability hole beside it.
THE BLOCKER. The `"panel"` commit profile skipped preflight checks 2-4
on the claim that a panel result never touches a document window. That
claim is false: panel placement FALLS BACK to an ordinary document
window when the frontend is not `panel_capable` or its one side slot is
dedicated elsewhere -- `apply_placement` says so in its own comment --
and then installs the result there. So a `"panel"` commit could replace
a NEWER document with every stale-intent guard skipped: capture A, the
user opens B, the continuation lands, B is gone. That is the exact
failure `commit_to` exists to prevent, reached through the profile
meant to be the safe one.
WHY NOT A PREFLIGHT PREDICTION. Revision 6 proposed predicting the
fallback at preflight, arguing nothing could change in between because
the body cannot `await`. Refusing `await` prevents another COROUTINE
interleaving; it places no restriction on the body itself, which is
arbitrary Lua running synchronously and can invalidate the snapshot in
two statements -- take the panel, set it `dedicated`, then request a
side display. No preflight predicate closes that, however phrased: the
measurement is taken before the thing it measures is decided.
WHAT THIS DOES INSTEAD. `EditorCore::display_buffer` refuses between
`resolve_placement` and `apply_placement` when a side request resolved
to `PlacementKind::Ordinary` under an active `"panel"` contract whose
destination fails the document preconditions. That is the first moment
the fallback is a fact rather than a guess, and refusing before
`apply_placement` means a refused fallback mutates nothing. The
contract rides on the core, installed and restored by the same
`ScopedFrontendGuard` that scopes the frontend, so a profile can never
outlive the body that declared it; the field is crate-private, so Lua
cannot claim a profile for a placement it did not commit to.
The preflight predicate SURVIVES as an early refusal and not as the
guarantee. `panel_placement_can_fall_back` still gates the relaxation
in `commit_destination_refusal`, so the statically knowable case -- a
frontend that cannot render a panel at all, and will not acquire the
capability mid-body -- refuses before the body allocates a buffer,
registers a handle and paints. That is the same reason `commit_to`
preflights at all. Both layers are pinned, and neither pin subsumes the
other.
The four document checks now live once, in
`EditorCore::document_destination_refusal`: they are evaluated from two
sites, and two hand-written copies is how a backstop ends up weaker
than the thing it backs.
THREE DELIBERATE LIMITS, each a different decision rather than a
stricter version of this one. The document profile is untouched --
re-running its checks at placement would newly refuse dired's own
documented panel path, which is a preservation-suite stop signal. Only
a fallback is guarded, not every `Ordinary` placement -- a `"panel"`
body calling `display_file` is pinned as succeeding. And the refusal is
of the PLACEMENT, not of falling back: a `"panel"` commit with an
intact destination still degrades gracefully into the document window,
because turning graceful degradation into an error would regress every
consumer that works today on a frontend without panel capability.
THE SECOND HOLE. `commit_profile` did `name.to_str()?`, but Lua strings
are BYTE strings, so a `string.char(255)` profile hit mlua's generic
UTF-8 conversion error before `BAD_COMMIT_PROFILE` was ever
constructed -- the same reachability class as the `Option<String>`
defect revision 5 fixed, one layer down. The comparison is on bytes
now, and the invalid-UTF-8 row joins the number/table/boolean rows
asserting on message content.
FOUR DOC SITES repeated the false claim (`ViewDestination`'s own doc
twice, `capture_view_destination`, `ViewDestinationLua`) and are
corrected. Nothing else relied on it: dired, the only Lua `commit_to`
consumer, takes the two-argument document profile and already had all
four checks; `compile.lua`'s `already_in_panel` queries live state; and
the terminal adopter's rollback keys off `created_side`, already false
on a fallback.
Tests: 12 pins, up from 8. Three carry the enforcement split and none
subsumes another -- the pre-established fallback (both causes, the body
must not run), the inside-the-body transition (the body runs, the
result must not land), and the graceful fallback (a valid destination
still lands). Mutation-checked four ways; the pattern of which rows
survive each mutation is in `docs/active-work.md`.
`journey_acceptance` (47) and `dired_acceptance` (31) pass UNCHANGED.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Section-local edit to the lane's own block; several lanes edit this file
concurrently.
Records the two commits, the green gate line (both preservation suites
unchanged), the mutation checks that show a matrix of deliberate
omissions is not passing vacuously, and the two places the framing did
not match the tree:
- the rename was 11 references across 5 files, not 8 across 4 —
`src/daemon.rs` also calls the capture;
- Q#DC-4's "frontend with no document window" is a DEFENSIVE branch.
Q#BP6 asserts a layout always keeps a non-side window, with a
`debug_assert!` in `non_side_target` that fires under `cargo test`, so
a registered frontend in a healthy editor always has a live document
window. The decision stands, but #227 should not expect to meet that
refusal.
Neither changed a decision, and both are recorded rather than quietly
absorbed: the framing says "counted, not estimated", and the next reader
will check.
`ViewDestination`'s own doc comment is corrected in the same commit,
because it repeated the framing's over-claim ("a frontend showing only a
side window") in the one place a reader would trust it, and
`capture_view_destination` now says how reachable its empty pair
actually is. Code, not only ledger, since the ledger is not what someone
reads when they wonder whether that branch can fire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Journey Stage 1a built `pmacs.window.commit_to` for the continuation
boundary --- "the listing settles a tick or more later, and by then the
ambient frontend, selected window, and active buffer may all name
something else" --- but nothing outside the `path.open-directory`
dispatch could mint a destination to hand it. Every other async Lua
continuation therefore resolved its target from ambient state a tick
after the request, which is PR #227's P1a finding: run `git.status` in
frontend A, let B become active, and A's panel opens in B.
This is the prerequisite lane #227 blocks on
(`docs/destination-capture-framing.md`, revision 5). No adopter here:
git's adoption is #227's work, since a prerequisite that converts its
own first consumer cannot be reviewed separately from it.
Three parts.
**`pmacs.window.capture_destination()`** returns the same
nonconstructible userdata for the current frontend. No arguments, and
that is load-bearing rather than minimal (Q#DC-1): a Lua-supplied
frontend id would reintroduce exactly the fabrication hole the userdata
design closes. Profile-blind for the same kind of reason (Q#DC-4) ---
capture freezes what is true now, and what a commit depends on is
declared later, at the commit.
**`DirectoryDestination` -> `ViewDestination`**, with the Lua userdata
and the capture renamed to match. The captured triple was already
generic; only its name and its capture site were not. The document pair
is now `Option`, set and cleared together, so a frontend with no live
document window still captures rather than returning nothing and
sending the caller back to the ambient state this exists to replace.
**`commit_to(dest, body [, profile])`** (Q#DC-2/Q#DC-5), a closed set of
two. The document profile keeps all four preflight checks. The panel
profile keeps only the first --- the requesting frontend still has a
layout --- because a panel result does not occupy the captured document
window, does not replace its buffer, and does not need it to exist, so
each of the other three would refuse for a reason unrelated to what the
continuation does. Omitting the profile means `"document"`, which is
what makes the preservation promise contractual rather than careful:
every existing two-argument caller keeps all four checks by definition
of the signature.
The profile argument is typed `mlua::Value`, NOT `Option<String>`, so
its error is REACHABLE: with the narrower type mlua rejects a number or
a table during argument conversion, before the closure body runs, and
the message naming the accepted values never appears. That is the same
trap the `dest` argument documents one position to its left. `nil` and
absence are the same answer; anything else is refused by one message
that names both accepted values.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
PR #228 review found a correctness gap this lane made reachable. The
GPU dropdown derives its height, its visible window and its
selection-highlight offset from `rows.len()` — ONE logical row per
candidate — while a detail carrying a line break shapes into more
physical lines than that. One such row misaligns every row below it
and the highlight with it. The grid TUI has the same exposure from the
other side: it writes the description into a single-row suffix on the
minibuffer band.
## Why not reject CR/LF at registration
That was the obvious fix. It was implemented, measured, and abandoned
on evidence.
MCP tool registration renders a whole schema block into
`Command.description` — tool text, blank line, `Arguments:`, then one
line per argument (`tests/fixtures/pmacs-mcp-tools/init.lua:272`, a
`table.concat(lines, "\n")`, used at `:496`). And
`tests/m9_6_acceptance.rs:583-598` ASSERTS four of those lines. A
one-line guard in `CommandRegistry::define` fails 36 tests across
`m9_6` (19/25), `m9_7` (16/19) and `m9_8` (1/17), in both feature
configurations, and could only be made green by deleting a shipped
acceptance criterion.
So the one-line constraint goes where the constraint actually is: the
surfaces that have one row. `Command.description` stays free-form,
which it legitimately is.
## The change
`Command::description_first_line` clips to the first CR **or** LF — a
lone CR ends a line too, and an LF-only clip would pass a bare `\r`
straight through to the same surface. Both single-row consumers call
it: the semantic producer filling `MinibufferRow.detail`
(`src/semantic_render.rs`) and the TUI suffix (`src/editor.rs`). A
first line that is empty ships as `None` rather than `Some("")`, which
would draw trailing padding.
No ellipsis or truncation marker, matching the in-tree precedent and
the minibuffer's own width rule.
`describe-command` and `help.list-commands` are untouched and still
report every line. That is what makes this a rendering decision rather
than data loss, and it is asserted, not assumed.
## Precedent, already in this tree
The same MCP fixture clips a tool RESULT to its first line because
"a multi-line set_status would corrupt the row layout"
(`init.lua:277-285`), leaving width clipping to the frontend. Same
hazard class, same resolution.
## Verification
`src/command.rs`: a schema block registers AND clips, in all three
break forms; a single-line description is byte-identical after the
clip; an empty first line clips to empty.
`tests/discovery_stage2_acceptance.rs`: an MCP-shaped description
reaches the TUI band and the GPU row as one line, through the real
prompt path — with the full text still reachable via
`describe-command` asserted alongside, so a clip that deleted the
schema block everywhere would fail rather than pass.
`pmacs-gpu`: one physical shaped line per logical candidate row — the
geometry invariant the dropdown depends on.
Mutation-checked: neutering `first_line` to the identity fails all
four new break-handling tests
(`a_multi_line_description_registers_and_clips_to_its_first_line`,
`a_description_whose_first_line_is_empty_clips_to_empty`,
`a_multi_line_description_reaches_the_tui_band_as_one_line`,
`a_multi_line_description_reaches_the_gpu_row_as_one_physical_line`)
and leaves the two "did not tighten past purpose" tests green.
`Command.description`'s doc comment claimed "one-line", which the MCP
path openly violates. It now states the real contract and records why
a registration guard must not be re-proposed.
`m9_6`/`m9_7`/`m9_8` pass COMPLETELY UNTOUCHED, and are now named
gate suites so that stays on the record.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
`Command.description` has always been required and has always been
rendered by `help.list-commands`. It was missing at the one moment it
would change a decision: the M-x row. This carries it there.
COHERENCE.md §5's clause "M-x rows are still bare names", per
docs/discovery-stage2-framing.md revision 3.
## The wire half is additive, and the old variant is FROZEN
postcard is not self-describing: enum variants encode by index and
fields by position. Widening `MinibufferPrompt.candidates` in place
would make every v12–v22 peer MIS-DECODE the bytes rather than ignore
them — and gating the widened form at `>= 23` would not rescue them
either, because with only one variant to gate they would receive no
minibuffer message at all. Compatibility requires the old shape to
still exist AND still be sent.
So `MinibufferPrompt` is retained unchanged for `12..=22`, and
`MinibufferPromptRows { prompt, input, cursor, rows, selected, total }`
is APPENDED as the final variant, carrying `MinibufferRow { label,
detail: Option<String> }`. A new row type, not `CompletionPopupRow`,
whose `kind` is an LSP `CompletionItemKind` code with no honest value
for a command (Q#D2-1).
Exactly one of the two reaches any peer, ever. The producer selects on
the session's negotiated version, so the CLOSE necessarily uses the
same family as the OPEN — a rows session closed by a legacy clear
leaves the dropdown on screen forever. The daemon's write loop gates
both directions again, with the legacy gate written as a RANGE
(`12..MINIBUFFER_ROWS_MIN_VERSION`) rather than a floor, so a v23 peer
cannot receive both and double-render.
`ADVERTISED_PROTOCOL_VERSION` stays 20, untouched.
## The TUI half involves no wire at all
`src/editor.rs` contains zero references to `MinibufferPrompt`:
`paint_minibuffer` reads `core.minibuffer` directly. So it reads
`Command.description` from the registry in-process, which is why this
half is independent of the bump.
Clipping is three ORDERED steps (§3.4), and the guarantee is "never a
PARTIAL name", not "the name always survives" — the prompt and typed
input consume the budget first, so the remainder can be too small even
for the bare name. If the whole name does not fit, the suffix is
omitted entirely; only once it fits is a description attempted; a
description that does not fit whole is dropped, leaving today's
`[name]`. No ellipsis stub, and no prefix of a name is ever emitted.
## Verification
`src/protocol.rs` gains this repo's FIRST literal postcard byte
fixtures: `minibuffer_prompt_v12_wire_bytes_are_frozen`, open and
cleared. A round-trip freezes nothing — it encodes and decodes with
the same types, so a field addition leaves it passing while every
shipped peer breaks. Bite-verified: reordering two fields of
`MinibufferPrompt` leaves `minibuffer_prompt_round_trips_through_postcard`
green and fails the fixture.
`line_wrap_facts_encoding_is_unchanged_by_the_v23_build` pins the
PREVIOUS final variant, per the handoff §4 rule that an appended
variant's own round-trip cannot detect a discriminant shift.
`tests/discovery_stage2_acceptance.rs` runs ONE daemon serving a v22
and a v23 session simultaneously, through the real M-x key path, and
asserts each receives its own variant AND ONLY its own — open and
close alike — by collecting every minibuffer message rather than
filtering for the expected one.
No cross-version cache test, deliberately (§3.2):
`SemanticRenderState::for_peer` bakes the negotiated version in at
attach and is dropped at detach, so a cache cannot span two versions.
A test for an impossible condition passes forever while teaching the
next reader that the hazard is real.
Five version assertions updated, each read before editing:
`src/protocol.rs` (the `PROTOCOL_VERSION` tripwire, renamed; and the
v6-floor ladder's accepted/rejected ranges),
`tests/statusline_segments_acceptance.rs`,
`tests/bottom_panel_stage2b_gpu_acceptance.rs`,
`tests/vterm_stage3_acceptance.rs`. No `ADVERTISED_PROTOCOL_VERSION`
assertion fired.
Gates: `scripts/gate --protocol --acceptance discovery_stage2_acceptance`
— all ten green, including the strengthened two-configuration sweep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Implements docs/git-integration-framing.md revision 5 (approved
2026-08-09). COHERENCE.md section 15's largest named gap gets something
to attach to: a user can now answer "what have I changed?" without
leaving the editor.
*git-status* a `pmacs.listview` panel over
`git --no-optional-locks -C <root> status
--porcelain=v2 --branch -z`. RET visits the file, `d`
shows its diff, `g` refreshes.
*git-diff* the file-level diff, in a generated buffer rendered as
plain text --- there is no bundled `diff` grammar and no
hunk model anywhere in the tree.
NO WIRE CHANGE: no pmacs-protocol edit, no PROTOCOL_VERSION bump, no
DecorationKind variant. That is load-bearing for scheduling, not a
coincidence --- gutter markers (Stage 2) need all three and must be
scheduled alone, while this lane could run beside another.
One additive `listview` change, and the framing was wrong to say there
would be none: an optional `keys` table on the open spec. `d` cannot be
bound from outside the primitive safely, because a name collision
disambiguates to `<2>` and the name a consumer passed is not necessarily
the buffer it got. Keys are INSTALLED ONCE with the panel's buffer and
COMPARED on reopen: `Keymap::bind` refuses duplicates, and the async
completion model re-opens on every refresh, so a naive implementation
would have errored on every successful refresh.
One config-registry setting, `git.enabled`, through `pmacs.config.define`.
Facts measured against real git rather than reasoned about, each pinned
by a test:
* `ProjectKind::Git` means a BARE repository, and a language marker
beside `.git` wins --- so pmacs reports `kind = "rust"` for its own
repository. This module never asks pmacs whether something is a repo;
it runs `rev-parse --show-toplevel` and lets a non-zero exit answer.
* `git diff --no-index` implies `--exit-code`: exit 1 means it
SUCCESSFULLY found differences. The untracked predicate is exit in
{0,1}; only >= 2 is failure. Under the naive predicate every untracked
diff --- the case `--no-index` exists for --- would render a failure.
* An unborn HEAD makes `git diff HEAD` exit 128. Detected from
`# branch.oid (initial)` in output already being parsed, never from a
second `rev-parse`. `AM`/`AD` carry both states and get two labelled
patches; rename/copy is asserted UNREACHABLE, because `git mv` on a
staged-but-uncommitted file yields `1 A.`, not a `2` record.
* Under `-z` a rename's origin is the NEXT NUL-terminated field, not a
tab-joined suffix, so the record tokenizer is new rather than ported
from `tests/fixtures/pmacs-magit/`. What ports is that fixture's
SEPARATION --- pure `parse_*` over a string --- and its case coverage.
The fixture is untouched: it exists to prove the package system can
host this, and bundled code becoming its dependency would make
`m8_6_acceptance` test less than it claims.
Coherence impact, stated per CLAUDE.md:
* Section 14: `*git-status*` is the FIFTH `listview` call site and the
first outside `lsp.lua` --- the evidence P5 asked for that the
primitive generalizes past its first consumer.
* Section 6: no new interaction island. `d` is an ordinary buffer-local
binding through the primitive's own path, so `describe-key` reports
the truth and `init.lua` can rebind it. The count stays at six.
* Section 9: NEGATIVE, and named as such. A spawned process does not
appear in `*workers*` --- that view is `async.lua`'s job list. This
adds a fifth background thing with no single place to see it. Every
spawn is labelled, which is better than anonymous, but a label is not
attribution. Accepted only because these are short-lived reads.
* Journey: no step added. Git is not a journey step and this does not
make it one.
Section 15's "no Git integration at all ... anywhere in the tree" is
narrowed here. It was literally false when written ---
`tests/fixtures/pmacs-magit/` is a tracked, installable package that
spawns git and parses porcelain v2 --- and the product gap it described
is what this closes.
Five things found by biting the suite rather than by reading, recorded
in docs/active-work.md: `listview.open`'s `seat_cursor` walks DOWN from
wherever the cursor is (so a re-opened panel lands one row low, and the
completion handler seats unconditionally from line 0); a selection test
that inserts ONE row above the selection is vacuous against exactly that
off-by-one; `{:?}` on a Rust string cannot build a `-z` fixture, because
Lua's decimal escape swallows the digit after `\0` --- which made one
test pass while parsing nothing; a path may contain a newline, so rows
escape it; and untracked rows sort after every tracked row.
Gates: scripts/gate --acceptance git_status_stage1_acceptance
--acceptance listview_acceptance --acceptance config_registry_acceptance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: frame QoL Stage 5, GPU horizontal scroll
Stage 4 merged as #222, so the lane advances to its last stage. Rule 4
still does not apply — the arc closes when Stage 5 merges, not before.
THE FRAMING'S FIRST FINDING CORRECTS STAGE 4'S. §1.3 there said the GPU
"needs a mechanism that does not exist", named it the fact most likely
to invert the cost estimate, and I endorsed the Stage 4/5 split partly
on that basis.
Half of it holds: `Scroll::horizontal` really is discarded throughout,
because glyphon 0.11 never applies it when placing glyphs — three
doc sites and three asserting tests. But that is not the only
mechanism. The document `TextArea` already carries an explicit `left`
origin and a `TextBounds` clip whose `left` is `gutter_clip_left`, and
horizontal scroll is `left: text_left - offset_px` with the clip
unchanged. glyphon then drops what falls left of the gutter — the same
"paint from column 0, clip at the edge" shape the grid renderer uses,
expressed in pixels. It is machinery the file already depends on, not
new machinery.
The split stays right for the reason that survives: the three consumers
Stage 4 named — caret (`code_byte_px`), decoration geometry
(`push_glyph_extent_rects`), hit testing (`gutter_aware_rel_x`) — each
produce x relative to `text_left()` and each need the same offset,
applied ONCE or they disagree. Shipping that inside Stage 4 would have
made one reviewable change into two unreviewable ones. But it was
justified partly by an overstatement, and saying so is cheaper than
letting a future reader inherit it.
No wire, no version bump: the GPU owns its viewport locally, exactly as
it owns `scroll_top` and `code_scroll_residual`. The parallel with
`ui.line-wrap` is misleading and the doc says why — the MODE is buffer
state and needed v22, the OFFSET is viewport state and needs nothing.
Five questions, each with my vote. Q#G3 is the one I am least sure of:
the GPU can resolve a proportional family, where "column" has no fixed
pixel width, so column-for-column parity with the TUI is unachievable.
I lean to defining the behavior in pixels and accepting imprecise
correspondence rather than gating a navigation feature on a font
choice — but that is a product call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: Stage 5 revision 2 — a clip, not just an offset
Two functional findings and two record repairs.
Q#G3 WAS BUILT ON A FALSE PREMISE, and the correction makes the lane
stricter rather than looser. Revision 1 said the GPU can resolve a
proportional family and proposed accepting a new TUI/GPU divergence to
accommodate it. It cannot: `family_is_monospace_everywhere` gates the
family across all four weight/style combinations,
`apply_font_facts` falls back when that fails, and
`unresolvable_and_proportional_families_fall_back` REQUIRES the
fallback. Answered as monospace-only by the font contract that already
exists — and the consequence is that the TUI-parity witness becomes
UNCONDITIONAL for every font the GPU supports. Revision 1 would have
introduced a font-dependent behavior difference to solve a problem the
codebase had already solved, in the lane whose purpose is removing
unchosen divergence.
"THREE CONSUMERS" WAS INCOMPLETE IN A WAY THAT WOULD HAVE SHIPPED A
DEFECT. Shifting the `TextArea` clips glyphon's text because glyphon
honors `TextBounds`. The manual quad and squiggle renderers have no
code-area scissor at all — nothing stops them painting into the gutter,
and today nothing needs to, because no code-relative x can be negative.
Scrolling makes that false.
So the framing now requires TWO shared things: one screen↔code
transform, and one code clip rectangle every code-relative painter
intersects with. The paths are tabulated with sites — caret rect
(`:9698`), caret-painted predicate (`:9734`), glyph extent rects
(`:9766`), inline math origins (`:9434`), completion anchor (`:7606`).
The two caret sites are the sharpest, and one of them falsifies a claim
revision 1 made: `:9734` has no left-edge test, so "the scroll
indicator inherits the fix" was false — `code_byte_painted` reuses it
and would call an off-left byte painted. And `:9698` does not merely
lack a check, it DOCUMENTS the absence as safe ("the caret x can't
precede `text_left`"). A comment asserting an invariant this lane
deletes is worse than silence.
Q#G2: "inert under wrap" was too weak. The offset must be RESET to zero
on the wrap transition, as the TUI already does — `horizontal_follow`
assigns `view_left = 0` on the wrap branch. Inertness hides a stale
value that reappears the moment the buffer toggles back to `truncate`,
before any cursor motion. G5 gains a witness that an inertness-only
implementation fails.
RECORDS. Rule 4's Stage-5 removal precondition was not actually met:
the handoff still described Stage 4 as upcoming work. Stage 4's durable
facts are now transferred — the unsnapped per-window column with a
per-line effective edge, the line-absolute walk, the three-way cell
designation, `Viewport::visible_cols` and its five adopters, the
wrap-branch reset, the `#[serde(default)]` persistence, and the absence
of any wire. The ledger's "Stage 4 ahead" / "Stage 4 plan" text is
corrected to Stage 5, and its Rule 4 note now says the removal is
legitimate BECAUSE those bullets exist.
And the journey-step claim is withdrawn. Revision 1 said this lane
completes journey step 4; step 4 is scored on welcome/help/tutorial
discoverability and COHERENCE.md:395 holds it Partial for reasons this
lane does not touch (`C-h` deletes a word, no tutorial). Restated as
preserving interface comprehension with no scorecard movement. §16 is
the direct target. Writing an unearned mark into a scorecard is how a
coherence document stops being ground truth.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: Stage 5 revision 3 — four corrections, one of them impossible
Q#G1 CONTRADICTED THE Q#G3 ANSWER IN THE SAME DOCUMENT. It still said
the GPU's font "need not be monospace" and that Q#G3 makes "column"
ill-defined — both falsified by the answer two sections below, in the
same revision that wrote it. The pixel-storage vote is unchanged, but
its reasons narrow to the ones that survive, and the conversion is now
stated as EXACT: columns × the supported monospace advance. That is
what makes the unconditional parity witness checkable at all.
Also removed `follow_cursor`, which I invented. The GPU's pass is
`ensure_caret_painted`, and it is now named rather than cited by line —
robust against the transposition that put these two sites at each
other's line numbers in review.
Q#G2 WAS MISSING THE BUFFER-SNAPSHOT RESET. The GPU zeroes `scroll_top`
and `code_scroll_residual` when a snapshot installs a new buffer; the
horizontal offset must reset there for the same reason. Without it a
buffer switch INHERITS the previous document's leftward viewport,
showing the new buffer scrolled sideways until a cursor motion repairs
it — a worse symptom than the wrap case, because nothing about the new
buffer explains it.
THE GUTTER ASSERTION WAS IMPOSSIBLE, not merely imprecise. Revision 2
proposed asserting that nothing paints left of `gutter_clip_left`. With
line numbers on, the gutter DELIBERATELY holds digit glyphs and
diagnostic-sign quads, so that assertion fails on a correct
implementation — a test that can only be satisfied by removing the
gutter. Replaced with the checkable form of the same intent: the gutter
rectangle is byte-identical before and after a horizontal scroll, and
the left-edge rule is checked against code-relative geometry only. It
still catches a code painter bleeding into the gutter, because that
changes those pixels.
THE COMPLETION ANCHOR HIDES, IT DOES NOT CLOSE. `completion_anchor_px`
already returns `None` when the anchor scrolls out, so nothing draws
while the daemon-owned completion state and its key handling are
retained; actual closure is `CompletionPopup { anchor: None }`, which
is the daemon's to send. Revision 2 said "closes", which would have had
a viewport-geometry lane quietly redefining when a completion ends.
Specified as: no completion paint while the anchor is off-left, popup
reappears when it scrolls back, session semantics unchanged.
Ledger drift fixed: it still called the framing revision 1 with five
questions open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: Stage 5 revision 4 — witnesses for the two rules that had none
Both additions cover requirements the framing had already stated and
then left untested, which is how a rule becomes a comment.
THE SNAPSHOT RESET (Q#G2). Revision 3 added the buffer-snapshot reset
and tested only the wrap one. The witness now scrolls buffer A to a
non-zero offset, installs a buffer B snapshot, and asserts the offset
is zero and B renders at its code origin BEFORE any `CursorByte`
arrives.
The pre-cursor scoping is the entire test. A later cursor motion
repairs the offset regardless, so a witness that waits for one cannot
distinguish "reset on snapshot" from "repaired on first motion" — and
the second is the defect. Same shape as the wrap witness, which is also
scoped to before any motion, and for the same reason.
THE MINIMAP (Q#G4). The vote is "no movement", and the implementation
already supports it: the minimap derives from the summary, the surface
dimensions and `scroll_top`, with no horizontal input. So the witness
pins an existing property rather than requesting work — which is
exactly why it is worth writing. An offset threaded one seam too far
would break it silently, and nothing else in G5 would notice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: Stage 5 approved, five questions resolved
G1 pixels with exact conversion via the supported monospace advance; G2
automatic cursor-follow only, zeroing on both the wrap transition and
BufferSnapshot; G3 monospace-only by the existing font contract; G4
minimap unchanged; G5 accepted whole, including the snapshot-reset and
minimap-stability witnesses.
The scope boundary is restated in both documents because it is what
keeps this lane small: local GPU viewport state, no wire message, no
protocol bump, no command surface, no minimap movement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* feat(gpu): horizontal scroll — the transform, the clip, and both resets
Stage 5, partial. The mechanism and lifecycle are in; two consumers and
the whole verification set are NOT yet done — see the tail of this
message, which is a status, not a summary.
WHAT IS IN.
The offset, `code_scroll_left`, in pixels (Q#G1). Column parity stays
exact because the code font is monospace by contract, so
`columns × advance` is a definition rather than an approximation.
Local viewport state: no wire, no version bump.
One screen↔code transform (`code_x_to_screen` / `screen_x_to_code`) and
one code clip (`code_clip_left` / `survives_code_clip_left`), which is
the pair framing §1.1 requires. Written before any consumer moved,
because five sites deriving the same offset independently is how the
caret and the glyphs it sits among come to disagree.
The glyph-side mechanism is one line: the document `TextArea`'s `left`
shifts while its `bounds.left` stays at the gutter, so glyphon clips
and the gutter keeps its own pixels.
BOTH LIFECYCLE RESETS (Q#G2), which were the two rules most likely to
be left as comments. The wrap transition zeroes the offset in
`apply_line_wrap` — inertness would park a stale value that reappears
the instant the buffer toggles back to `truncate`. The buffer snapshot
zeroes it beside `scroll_top` and `code_scroll_residual`, or a buffer
switch inherits the previous document's leftward viewport and shows the
new buffer scrolled sideways until a cursor motion repairs it.
`code_caret_rect_in_clip` gains its left-edge test, and its comment is
REWRITTEN rather than extended: it used to assert "the caret x can't
precede `text_left`", an invariant this stage deletes. A comment
asserting something a later stage falsifies is worse than silence. That
also repairs `code_byte_painted`, which reuses it — revision 1's claim
that the scroll indicator "inherits the fix" was false precisely here.
`gutter_aware_rel_x` is now the exact inverse of the transform, with
the gutter clamp applied in screen space first: a click in the gutter
band means "the first visible column", which after scrolling is the
offset, not column 0.
The completion anchor HIDES when scrolled off-left and does not close —
the daemon owns completion state and its key handling, and closure is
`CompletionPopup { anchor: None }`, which is the daemon's to send.
`horizontal_follow` mirrors the TUI's: automatic only, scroll just far
enough, so a caret already visible never moves the view. It runs after
`normalize_code_scroll` because it reads the caret's laid-out x, which
vertical normalization can change.
WHAT IS NOT IN, and must land before this is reviewable:
- `push_glyph_extent_rects` — washes, squiggles and selection extents
still paint at unshifted x and are not cropped at the gutter.
- Inline math origins (`:9434`) — same.
- Every Q#G5 witness. The 228 existing GPU tests pass, which says
only that nothing regressed at offset 0; not one of them exercises
a non-zero offset.
Gates so far: fmt; clippy --workspace --all-targets -D warnings;
PMACS_REQUIRE_GPU=1 -p pmacs-gpu 228/0; git diff --check. The full
two-configuration sweep is deliberately not claimed — the lane is not
finished.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* feat(gpu): the last two painters move, and twelve witnesses say so
Completes Stage 5. `62fb93e` landed the transform, the clip and both
resets but left two code-relative painters at unshifted x and the whole
Q#G5 witness set unwritten; its 228 green tests said only that nothing
regressed at offset 0.
The two painters:
- `push_glyph_extent_rects` — selection/search washes, peer presence
and diagnostic squiggles. Shifted through `code_x_to_screen`, then
CROPPED at the gutter rather than dropped: a selection running in
from off the left edge must paint the part that is visible. That is
the same boundary Stage 4's review caught the TUI painter getting
wrong, and it would have been easy to reproduce here.
- Inline math. The glyph mini-buffers only needed their origin moved —
their layer already carries the code area's `TextBounds`. The
fraction rules are quads in the background batch with no scissor of
their own, so those are cropped by hand.
`crop_to_code_clip_left` is the crop, and `survives_code_clip_left` now
delegates to it, so a caret the crop would discard is never painted.
One boundary rule, not two that agree today.
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`.
That battery earned its keep immediately. The gutter byte-identity
test's "the code area must actually have moved" assertion is satisfied
by a decoration wash and the caret alone, so it PASSED with
`TextArea.left` pinned to `text_left` — the entire glyph-side mechanism
was unwitnessed and nothing in review would have shown it. Its
replacement isolates the glyph layer: no decorations, and a source line
carrying no caret, whose band is blank at offset 0 and inked after.
ONE DELIBERATE STEP OUTSIDE THE APPROVED SCOPE, and it needs a ruling.
Q#G5 asks for frontend agreement that is "checkable rather than
asserted". Two tests in two crates asserting the same literal is not
that; it is 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:📜: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.
The cost is that Stage 5 now touches `src/editor.rs`, which "local GPU
viewport state" does not cover. No wire message and no version bump —
the same argument `classify` already makes. If rejected, reverting is
small: restore the four-line conditional, drop `follow_left` and its
four protocol tests, rewrite the parity witness as a two-sided pin.
GATES, both configurations, five ambient roots isolated: fmt; clippy
`--workspace --all-targets -D warnings`; `--lib` 1920 and `--lib
--features crdt` 2105; horizontal_scroll 11, long_line_readable 3,
line_wrap 6, full_grid_resync 1; `PMACS_REQUIRE_GPU=1 -p pmacs-gpu`
239; `-p pmacs-protocol --lib` 29; both full workspace sweeps;
`git diff --check`.
TWO SWEEP FAILURES, NEITHER THIS LANE'S, both logged:
- R8, new row: `flat_listview_consumers_render_byte_identically...`
fails DETERMINISTICALLY, and the merge-base control is done — it
fails identically on `main`. The row renders with a leading
directory stripped; it is a prefix strip, not width truncation, and
the mechanism is NOT diagnosed. Deliberately not fixed here.
- U3: the R7 selector failed once and passed on rerun. Recorded as a
new incident, NOT an R7 match — different flavor, and its fragments
are unverified because I filtered the sweep output before reading
it. U2 records me making that exact mistake already. Sweeps go to a
file from now on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* fix(gpu): the completion anchor is a point, and the witness now says where
Review round 1. One defect, and a lesson about the witnesses that
missed it.
THE DEFECT. `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. The predicate is
`screen_x + w > code_clip_left()`, so an anchor up to a whole line
height left of the gutter "survived". `completion_dropdown_rect` bounds
`ax` against the right margin only, so that x reached the popup's left
edge and painted over the line numbers.
An anchor is a position between glyphs. It has no width, and the popup
it places is drawn to its right. So the predicate is a point:
`screen_x < code_clip_left()`.
The absent left clamp downstream stays absent, deliberately. This
predicate is what guarantees `ax >= code_clip_left()`; a second clamp
would be a duplicate of the same rule, which is the failure mode this
stage's shared-transform design exists to avoid. It is witnessed
instead.
THE LESSON, which is the more useful half. The existing test placed the
anchor 200px off-left — and 200px off-left fails a width-based
predicate too, so it stayed green straight through the defect. The
mutation battery agreed with it, because every mutation asked only
whether REMOVING a check was caught, never whether the check had the
right shape.
A boundary must be tested AT the boundary. The new witness straddles it
by ±0.05px — the same anchor either side of the edge, which no
width-based predicate can separate — and additionally asserts the
popup's own left edge stays out of the gutter, making "no left clamp
needed downstream" a checked claim rather than a comment. Verified both
ways: the new witness fails against the original predicate, the old one
passes against it.
THE AUDIT that finding prompted. Stage 5 has one other left-edge
predicate, the caret's. Its use of `survives_code_clip_left(rect.x,
rect.w)` is correct — a caret quad genuinely is `CARET_WIDTH` wide —
and it was also only tested far from the edge. It is now walked ACROSS
the boundary a column at a time, asserting painted carets are wholly
inside the code area and hidden ones wholly outside.
That pins an argument that was load-bearing and invisible: because
`horizontal_follow` snaps to whole columns, a caret is never partly
behind the gutter, since `CARET_WIDTH` (2px) is far below any code
advance. Substituting `rect.h` for `rect.w` — the exact error above —
fails it. An over-width smaller than one advance does not, and that is
the invariant rather than a gap.
SCOPE. `follow_left` recorded as the one approved exception to "local
GPU viewport state" in the framing doc, new §1.2a: what it is, why the
Q#G5 parity witness cannot be real without it, and what it does not do
— no viewport state moved, no wire message, no version bump.
GATES, both configurations, five ambient roots isolated, sweeps
redirected to files per U3's lesson: fmt; clippy `--workspace
--all-targets -D warnings`; `--lib` 1920 and crdt 2105;
`-p pmacs-protocol --lib` 29; `PMACS_REQUIRE_GPU=1 -p pmacs-gpu` 241;
horizontal_scroll 11, long_line_readable 3, line_wrap 6,
full_grid_resync 1; both full workspace sweeps; `git diff --check`.
The only sweep failure is R8, confirmed by its recorded fragments —
pre-existing, deterministic, merge-base controlled against `main`, and
not this lane's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: Stage 5 is PR #223, head 55faa45
The ledger said "no PR opened yet", which stopped being true the moment
it was. Records the PR, its head SHA, and the standing do-not-merge.
Rule 4 still applies at merge, not now: the long-lines lane stays until
#223 lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: the tip is the ref, not a SHA the commit itself invalidates
The previous commit wrote "head 55faa45" into the ledger and, by
existing, made it false — recording the PR moved the head to 4902048.
A SHA pinned in a document that the act of writing it stales is a trap,
not a record.
The ledger already states the correct convention two paragraphs down
("the authoritative tip — the ref, not a SHA"); this follows it, and
says to verify CI against the PR's live headRefOid.
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>
Stage 4 of the QoL arc, framing revision 4 (approved). Under
`truncate`, text past the right edge was UNREACHABLE; moving the cursor
now brings it into view. Automatic only — no commands, no bindings, no
new interaction island (Q#HS2).
THE CONTRACT. `view_left` is an unsnapped per-window display column
(Q#HS7(a)), and each line derives its own effective edge during the
walk it already performs from column 0. Starting at 0 is not laziness:
tab expansion depends on the absolute column from the line start, so a
walk beginning at the edge would put tab stops in the wrong place. The
walk stays line-absolute and only the emit translates.
Where the edge bisects a wide glyph on a given line (Q#HS7(c′)), its
trailing cell paints a styled BLANK rather than a `Continuation` — that
glyph means "the cell before me is a wide glyph's head", and here that
cell is off-screen, so emitting it would name a cell nobody painted.
The mapping designates that cell to the glyph's START byte, which keeps
`byte_at_place` total over visible cells and makes the character the
user scrolled toward clickable. Tabs keep FORWARD rounding (Q#HS7(c″))
— preserved, not chosen.
DECORATIONS TRAVEL WITH THE TEXT. The first version of this commit
translated the base glyph walk and nothing else, which split the frame
in half: at `view_left = 10` a glyph from source column 10 painted at
screen column 0 while its syntax style, diagnostic underline, search
wash and `BufferStyleOverlay` span painted at screen column 10 — or
vanished. Decorations drifting off the characters they describe,
silently, and only once a window had been scrolled.
Every such site carried the same two lines (`start_col.min(max_cols)`,
`end_col.min(max_cols)`), correct only while the left edge was pinned
at zero. `Viewport::visible_cols` is now the one rule all FIVE adopters
share — syntax/LSP styling, diagnostic underlines, search washes,
`BufferStyleOverlay`, and the selection painter — so a future decorator
inherits the translation instead of re-deriving it. It also subsumes
the old `end_col <= start_col` guard rather than sitting beside it.
`StyleSpanOverlay` and `VirtualCellOverlay` are deliberately untouched:
they are documented as viewport-relative, so translating them would be
the mirror defect.
The selection painter was nearly a sixth site with its own copy of the
rule, which I justified by a width it supposedly needed and the
viewport lacked. That was FALSE — the render viewport's
`cell_size.cols` is already `rect.size.cols - gutter_w` and its origin
already sits past the gutter. It now takes that same viewport and drops
its `rect`/`gutter_w` parameters entirely. A canonical rule with one
honest exception is not canonical.
The selection painter had the same defect with a worse failure mode: it
asked `pos_to_display` through the LIVE context, which returns `None`
for a position left of the edge, so a selection beginning off-screen
and reaching into view took `continue` and painted NOTHING. That is the
common shape, not an edge case — select rightward from column 0 past
the window width and the view scrolls with the cursor.
TWO THINGS THE TESTS FOUND, both in `pos_to_display`. My framing note
said a caret sits between characters so never lands inside a glyph;
true for the caret, false for the DESIGNATION direction — the glyph's
start byte must map to its visible trailing cell, so `screen_col` needs
the straddle rule and not a bare subtraction. And the `take == 0` early
return short-circuited the translation entirely, so byte 0 looked
visible at every offset.
`view_left` is inert under `wrap` BY CONSTRUCTION —
`LayoutCtx::effective_left` and `Viewport::left_edge` return 0 while
wrapping — rather than by every caller remembering.
Persisted per leaf at DESKTOP_VERSION 1 (Q#HS5) with both approval
conditions: `#[serde(default)]` and a literal v1 JSON fixture omitting
the field, hand-written because a generated one would gain the field
and prove nothing.
Also: `view_left: window.view_left` in the render viewport, not a
literal 0. My mechanical fill put 0 there and it is EXACTLY the
`aa3cd4d` defect — coordinates and the indicator following the scroll
while the painter stays pinned at column 0.
BITE, per clause. Forcing `bisected = false` fails the multi-line
straddle witness; dropping the backward designation fails the
round-trip witness; removing `#[serde(default)]` fails the v1 fixture;
pinning `visible_cols` to an absolute clamp fails all three decorator
witnesses; restoring the selection painter's live-context lookup fails
the off-screen-start selection witness. Each alone. And with selection
now reading the shared helper, pinning `visible_cols` to an absolute
clamp fails the selection witnesses TOO — which is the check that the
duplication is really gone rather than merely reworded.
One unrelated red, logged as R7 in ci-red-signatures.md — the first
this session with a COMPLETE signature, so a matchable row rather than
a U note. `pmacs-gpu`'s managed-retry attach hit a BrokenPipe once
under full-sweep load and did not reproduce (6 isolated runs plus a
clean 113-target sweep). Per the rerun rule that is intermittence only,
and the row explicitly does not claim harmlessness. Not attributed to
this lane: Stage 4 touches no `pmacs-gpu` file and adds no wire
surface.
Gates: fmt; clippy --workspace --all-targets -D warnings, both
configurations; `cargo test --workspace --no-fail-fast -- --skip
basedpyright` 113 targets exit 0, and the same with --features crdt,
113 targets exit 0; git diff --check. No protocol change, so no version
bump and no protocol-bump matrix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Closes Stage 3's remaining obligations.
THE PTY TEST. Every other test in this lane checks a mechanism.
tests/long_line_readable_acceptance.rs checks the complaint: the
shipped binary, a real 80x24 PTY, one source line 200 columns wide,
and an assertion that its tail marker reaches the host. It bites —
`scripts/bite HEAD~2 src/editor.rs --test long_line_readable_acceptance`
against the pre-aa3cd4d editor never paints TAILZQX in 20s.
Its truncate control (an isolated init.lua pinning the mode) is what
makes that marker discriminating, and it is also the honest statement
of what truncate costs today: those bytes are not off-screen, they are
unreachable until Stage 4.
What it does not prove: the workspace has no screen model and no
vt100/termwiz/vte, so this shows the tail was WRITTEN to the terminal,
not which row a human would point at. That is nonetheless the whole of
the report — under truncation the bytes are never emitted at all.
§1.1 WAS WRONG, FOR NINETEEN REVISIONS. `editing.fill-column` is not
an orphaned registry setting "of the exact shape Stage 1 just fixed".
Both cited occurrences are inside `#[cfg(test)] mod tests` — fixture
names in round-trip tests covering one setting per ConfigKind. Two of
those five names are real; three, including this one, are defined
nowhere else in the tree. There is no shipped setting, so the Q#LL4
deliverable "sharpen its description" had no object.
The mechanism is worth more than the correction. A grep hit at a src/
path, a genuine `r.define(...)` call that is real API usage rather
than a mock, and `#[cfg(test)]` about fifty lines above the citation.
Every later revision inherited the conclusion instead of the evidence,
and three review rounds reasoned about the consequences of an orphaned
setting rather than re-checking that it existed. A file:line citation
is not a substitute for reading the scope it sits in.
Had it gone unchecked into implementation, Stage 3 would have shipped
an edit to a unit-test fixture believing it was rewording a
user-visible setting — a no-op with a misleading commit message.
§1.1 is withdrawn in place, keeping the original text and the
reasoning that produced it; §6's answer is unchanged (a setting that
does not exist is a stronger reason not to adopt it) and its premise
corrected. Both fixture sites now say they are fixtures. The approval
is not reopened: nothing else in the document rested on §1.1, which
argued for a display setting separate from fill-column — which is what
shipped.
AND ONE UNCLASSIFIABLE RED, logged as U1 in ci-red-signatures.md. A
`-p pmacs-gpu` run went 227/1 once; every run since is 228/0. The
failing test name was NOT captured, because I piped that command
through `tail -3` and discarded the failure block above the summary.
36 later runs are clean, 6 under deliberate concurrent load — which
per the rerun rule establishes intermittence only, and without a
selector not even that. Deliberately NOT matched against A1 despite
A1 also being GPU-headless-under-load: matching requires an exact
selector and every required fragment, and calling a shapeless red
"probably the known one" is the reputation-by-adjacency that file
exists to deny.
Gates: fmt; clippy --workspace --all-targets -D warnings; --lib
1917/0; --lib --features crdt 2102/0; line_wrap 6/0;
long_line_readable 2/0; folding 21/0; folding_stage2 48/0;
full_grid_resync 1/0; config_registry 16/0; m4 150/0;
PMACS_REQUIRE_GPU=1 -p pmacs-gpu 228/0 (see U1); git diff --check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The frame resolved ui.line-wrap, recorded it on the window, and fed it
to the coordinate mapping and the scroll indicator --- while the
viewport handed TextView::render a hard-coded Truncate. With the
default of wrap, that meant the cursor was placed for wrapped text and
the indicator reckoned against wrapped rows while the text was still
clipped at the edge. The worst possible split: every part that reports
where things are agreed, and the part that draws them did not.
The viewport now reads window.last_wrap, so it consumes the same single
resolution as everything else rather than resolving again.
How it survived: the edit was in a script that raised on a LATER
assertion, so nothing was written; a follow-up script's replace then
matched nothing and silently did nothing. Every test I had asked "is
the mode right?" and none asked "is the text wrapped?", so all of them
passed.
Hence the new witness reads the GRID. the_default_actually_wraps_the_
painted_text goes through RenderState and reconstructs rows from the
emitted CellDelta spans, for two reasons: the defect lived in the
DRIVER, between the resolved mode and the viewport it built, so a test
building its own viewport would have passed against it --- and the
spans are what a TUI actually consumes. truncate_clips_the_painted_text
is its control, so the pair is discriminating rather than merely true.
It bites: restoring the hard-coded Truncate fails the wrap witness
while all five other tests keep passing, which is exactly the shape
that let it through.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1917/0,
crdt 2102/0, line_wrap 6/6, tab_width 2/0, folding 21/0,
full_grid_resync 1/1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
format_scroll_indicator derives every branch from total_lines, and its
first branch is `if total_lines <= 1 { return "All" }`. A one-line
buffer wrapping to fifty rows still has one line, so the indicator
claimed the whole buffer was on screen while forty-nine rows sat below
the viewport.
Under wrap the TUI now classifies through pmacs_protocol:📜
All/Top/Bot from LOCAL predicates, the percentage from byte position.
Under truncate it calls the existing formatter with the same arguments
in the same units, so that output is byte-identical by construction and
every existing formatter test stays valid.
The local predicate needed a new fact, and getting it right took two
attempts. TextView::render now records whether the walk ran out of
BUFFER before it ran out of rows --- recorded by the walk rather than
recomputed, because under wrapping it cannot be derived from line
counts and a second derivation could disagree with what was painted.
The first version was `line >= line_count`, which is true whenever the
last line was STARTED. Under wrapping that is exactly the wrong moment:
a fifty-row line begun on the last visible row would report the buffer
end as on screen. It needs `row_offset <= max_rows` as well --- the
rows it wanted actually fit. The witness caught it; the reasoning did
not.
The GPU half is NOT in this commit, and that is deliberate. I wired it,
and extreme_sizes_render_with_contained_popups failed --- correctly.
That test asserts the frame diff between two renders is confined to the
completion popup, and my last_visible predicate read self.view_range,
which the popup's reshape moves, so the status text changed between the
two frames. The predicate was also simply wrong: view_range includes
overscan, so it can reach EOF while the last row is off-screen. A
stable, correct local predicate on that side needs an understanding of
the slice/overscan relationship I do not have yet, and a guess there
would ship the same class of defect this stage exists to remove.
Reverted; the GPU indicator remains open work.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1917/0,
crdt 2102/0, pmacs-gpu 224/0, line_wrap_acceptance 4/4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The setting exists now, and both frontends honor it. This is the commit
that turns the stage on: the grid renderer, the coordinate mapping, and
the GPU were all built and tested against a mode nothing could set.
ui.line-wrap is buffer-local, an enum of wrap and truncate, defaulting
to wrap. A closed set rather than a string, so an unknown value is
impossible rather than handled --- and so adding "word" later is
additive. It lives in ui., not editing.: editing.* is buffer-editing
behavior, this changes only how text is SHOWN, and the two existing
ui.* settings carry a gpu- prefix to mark frontend-specific ones, so
the absence of a prefix is what says "both frontends".
Resolved exactly once. The render loop reads it per window per frame
and records it on the window; the viewport is built FROM that, and
Window::layout_ctx hands the same answer to all twenty coordinate call
sites. Nothing re-resolves, so two callers cannot disagree about one
buffer. The earlier write-back from viewport to window is removed as
circular now that the resolution precedes the viewport.
Semantic frontends are told over LineWrapFacts, gated at v22 in the
producer and again in the daemon write loop. The dedup key is the
(buffer, wrap) PAIR, and that is the whole design rather than a
micro-optimisation: font size is global, so caching it by value is
right, but wrap mode is buffer-local, so a value-keyed cache stays
silent when the user switches from a truncating buffer to a wrapping
one --- a real mode change with no config event behind it.
a_buffer_switch_re_emits_the_wrap_mode pins that, and it bites: keying
the cache on the mode alone fails it while every other test still
passes, which is exactly how the bug would have shipped.
Two existing tests moved, and both moved for the right reason rather
than because they were stale. The semantic allowlist gains the variant,
and the first-frame count goes 7 to 8 --- the second is really an
assertion that the attach trigger works: a semantic frontend that is
not told on its first frame never learns the setting at all.
Also ships ui.toggle-line-wrap, and its status line says the quiet part
--- turning wrapping off makes text past the right edge unreachable
until horizontal scrolling lands in Stage 4. That belongs where a user
sees it, not only in the framing.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1916/0,
crdt 2101/0, pmacs-gpu 224/0, tab_width 2/0, folding 21/0, gui_zoom
15/15, full_grid_resync 1/1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
All three were review findings, and all three shared a shape: the code
that decided a mode and the thing that held it were allowed to differ.
The GPU compared against a shadow field. code_wrap started at
Wrap::None while the cosmic-text buffer had never had set_wrap called
at all, so it was still on the constructor default WordOrGlyph. Since
apply_line_wrap short-circuits when the request already matches, the
FIRST wrap: false was a no-op and the document kept word wrapping. The
existing test hid it by sending true first, which synced the buffer as
a side effect.
Fixed twice over. The document buffer now declares its wrap at
construction like every other buffer in the file --- Wrap::Glyph, not
None, because ui.line-wrap defaults to wrap, so a frontend told nothing
(or talking to a pre-v22 daemon that never will tell it) should already
be in the default mode. And the shadow field is GONE: apply_line_wrap
reads self.buffer.wrap() instead. A cached copy can disagree with the
authority; reading the authority cannot. Same principle as byte
anchoring and the fold-projection cache key.
The first attempt at that fix set Wrap::None at construction, which
made truncate the pre-message default and broke eleven tests --- the
GPU had always wrapped, and the setting's default is wrap. The failures
were right and the change was wrong.
A one-column viewport shoved every wide glyph down a row. The rule
moves a double-width glyph to the next row when it will not fit in the
cells left, but at one column it will not fit there either --- so a
single CJK character rendered on row 1 with row 0 left blank, and
clipped anyway. Now it only moves when the next row could actually hold
it (max_cols >= 2); below that it clips in place, which is what
Truncate does at the edge for exactly the same reason.
A zero-column content area panicked. Under Wrap the first
col >= max_cols test is true immediately, so the walk advanced a row
and then indexed column 0 of a zero-width grid. Reachable whenever the
gutter consumes the window's width. paint_line now returns before the
walk, and put() refuses out-of-range columns as a second line.
Four witnesses, all biting. The GPU one took three attempts to make
discriminating: wrap-versus-truncate could not see the bug (both modes
differ from each other either way), and a row-count comparison between
spaced and solid text did not discriminate at the width I chose ---
measured, not assumed. Asserting buffer.wrap() directly does, and fails
with left: WordOrGlyph, right: Glyph.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1914/0,
crdt 2099/0, protocol 25/0, pmacs-gpu 224/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The GPU is not a grid consumer --- it ignores the CellDelta family and
lays out locally --- so ui.line-wrap reaching the viewport reaches only
the TUI. Without a wire message, setting truncate would change one
frontend and leave the other wrapping: exactly the cross-frontend
disagreement this stage exists to remove.
LineWrapFacts is appended after PanelFrame, the final v21 variant, so
no postcard discriminant moves. PROTOCOL_VERSION 21 -> 22;
ADVERTISED_PROTOCOL_VERSION stays at 20, per its own doc --- moving the
advertised baseline is reserved for changes that cannot be expressed
additively, and this one can. A v21 frontend negotiates v21, never
receives the variant, and keeps its behavior.
It carries buffer_id because the mode is buffer-local. That is also why
the daemon must resend on BUFFER SWITCH, not only on attach and config
change: font size is global, wrap mode is not, so moving from a
truncate buffer to a wrap buffer changes the effective mode with no
config event at all. The GPU handler leans on that --- it ignores a
message for any buffer other than the one on screen, rather than
keeping a per-buffer cache.
On the GPU side the document buffer had never called set_wrap, so it
was running on cosmic-text's constructor default of WordOrGlyph: word
wrap nobody chose. code_wrap makes it explicit in both directions and
settles on Wrap::Glyph. Character wrap is what the grid can implement
identically without pulling UAX #14 into it, and what Emacs does by
default. GUI users lose word wrap --- a deliberate, documented trade
for the two frontends agreeing, and it belongs in the release notes.
Changing wrap reflows the document exactly like a font change, so the
retained scroll anchor is repaired through the existing
normalize_code_scroll rather than left pointing at a row that no longer
exists.
Three test updates that were NOT stale assertions. The version pin and
the resume ladder both had to widen, and the GPU's byte-exact bootstrap
test failed because SUPPORTED_PROTOCOL_VERSIONS still ended at 21 ---
the handshake was genuinely rejecting v22. That test earned its keep.
Two new GPU witnesses. the_gpu_honors_an_explicit_non_wrap_mode is the
discriminating case framing section 7 asked for: the existing
wrapped_caret test passes against a wrap nobody configured, so it
cannot tell "honors the setting" from "the default happened to match".
Comparing row counts across the two modes can.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1912/0,
crdt 2097/0, protocol 25/0, pmacs-gpu 223/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
pos_to_display and display_to_pos now take a LayoutCtx and DisplayCoord
carries a sub_row. The asymmetry is the whole audit strategy, and it
played out as designed.
Breaking on the way in: adding a required parameter turned the audit
into 61 compiler errors instead of a grep. Additive on the way out:
sub_row defaults to 0, so overlay_paint's `row - view_top` and vertical
motion's bounds check stayed CORRECT rather than merely findable ---
neither needed touching. row is still the source line. Redefining it as
a visual row would have broken both silently.
Two structural gaps this surfaced, neither in the framing:
Window recorded last_visible_rows but no width, so the coordinate
callers --- vertical motion, paging, overlay placement --- had nothing
to build a context from. Added last_content_cols, taken from the
viewport the renderer actually used rather than recomputed: a second
derivation could disagree, and the disagreement would show only as a
cursor on the wrong row. Content width, not window width, because the
gutter grows at the line-count digit boundary.
The mode had the same problem one level up. It is buffer-local and the
registry has no ambient buffer, so only the driver can resolve it ---
but every consumer holds a window, not a registry. Window::last_wrap is
recorded beside the width and read through Window::layout_ctx(), so
there is ONE resolution consumed everywhere. When ui.line-wrap is
registered, only the driver changes and all twenty call sites become
wrap-aware together. The alternative, each caller resolving for itself,
is how two callers end up disagreeing about one buffer.
One real regression, caught by the render tests rather than reasoning:
generalising row_of_byte into place_of_byte lost the boundary rule. A
byte landing exactly on a row edge reported (row, max_cols) instead of
(row+1, 0), so a viewport anchored there painted the wrong row. The fix
is the rule framing section 7 already settled --- the wrap position is
owned by column 0 of the NEXT row, because that cell always exists and
(row, max_cols) does not.
Five coordinate witnesses. Identity on every cursor boundary of a line
containing a tab and a CJK glyph, across four widths; projection to the
codepoint start for interior bytes, unchanged by wrapping; the two
distinct adjacent codepoints across a break mapping distinctly; row
staying the source line; and a truncate control. They bite --- forcing
the wrap branch off fails three, including the round trip.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1912/0,
crdt 2092/0, tab_width 2/0, folding 21/0, gui_zoom 15/15.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Stage 2 (GUI zoom, #220) landed. Stage 3 shares no code with it, so this
is a currency merge rather than a dependency --- taken now so the
Stage 3 PR opens against a base it has already been tested on.
# Conflicts:
# docs/active-work.md
The grid renderer can now continue a line onto the following rows.
Truncate is unchanged: --lib was 1900/0 before these tests and is
1907/0 with them, and every pre-existing assertion passes untouched.
advance_wrapped is the whole rule and it exists ONCE. row_of_byte and
paint_line both go through it because they must agree perfectly --- the
first decides which visual row the viewport's byte anchor sits on, the
second decides which row the text is drawn on. Two copies that drifted
by one row would scroll the buffer to a position it does not render: a
defect with no local symptom, and the same shape review has caught four
times in this lane's framing. the_anchor_row_matches_where_the_text_is_
painted pins the agreement across four widths and every character
boundary of a line containing a tab and a CJK glyph.
Three rules the walk had to settle, none of which the framing decided:
A tab fills to the row's end and stops; it never spans a wrap. Column 0
of the next row is itself a tab stop, so alignment survives the break
rather than being approximated. Carrying the remaining pad across would
land the next character at a column the tab-stop arithmetic never
chose.
A double-width glyph with one cell left moves to the next row whole,
rather than being split or half-painted. Truncate keeps its existing
behavior instead --- lead cell painted, continuation omitted. That is
arguably worse and it is deliberately not fixed here: Truncate must
stay byte-identical, and an improvement smuggled in beside a refactor
is how identity cases stop being identity.
The break belongs to the character that could not fit, so it is taken
before drawing rather than after the previous glyph.
Rendering clears every row up front now. Under Wrap a row's occupant is
not known until the line reaching it has been laid out, so clearing
cannot stay inside the per-line walk --- but every row is still blanked
exactly once, as before.
Seven tests. They bite: forcing the wrap branch off fails five of them,
including the anchor-agreement witness, while the Truncate control
keeps passing.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1907/0,
crdt 2092/0, tab_width 2/0, folding 21/0, listview 26/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Inert by construction. Adds the type and the Viewport field, sets all
31 construction sites to Truncate, and changes no rendering: --lib is
1900/0 and crdt 2085/0, the same counts as the parent commit.
The field is required rather than defaulted on purpose. A default would
have let 31 sites stay silent about which behavior they meant; a
required field makes each one state it, so the pre-existing sites now
read as deliberately unwrapped rather than merely untouched. The
compiler enumerated them, including five integration tests --- Viewport
is public API, so this is a real break, and the break is the point.
The render driver is pinned to Truncate too. The wrap path does not
exist yet, and exposing a mode before the cursor mapping honors it
would ship a setting that renders one thing and navigates another ---
the shape of defect this lane exists to remove, not add.
Two notes on getting here, since both were nearly landed:
The first mechanical patch matched every `folds,` line and put a wrap
field into function call sites and a FoldStore literal. Scoping the
insertion to Viewport literals cut it from 40 sites to 31. The compiler
caught it, but only because a struct field cannot be mistaken for an
argument; a same-arity call would have compiled.
While rewriting the character walk I changed the wide-character edge
case --- a double-width glyph with one cell left now breaking instead
of painting a lone lead cell. That is arguably better behavior and it
is NOT this commit's to make: Truncate must be byte-identical, and an
"improvement" smuggled in beside a refactor is how identity cases stop
being identity cases. Reverted; the walk is untouched.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1900/0,
crdt 2085/0, tab_width 2/0, listview 26/0, compile_mode 73/0,
folding 21/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Ctrl +/- had no effect whatsoever in the GPU frontend. Stage 1 (#219)
fixed what zoom did to the TUI; this is the other half.
NO RENDERING WORK. FontMetrics::scale already derived every GUI
dimension — code size, line height, status band, divider, menu rows,
minibuffer dropdown, gutter advance — and apply_font_facts already
re-metriced all seven buffers in one transaction. This drives the
preference that existed: two settings, three commands, and a restore.
Q#Z1 = (c). Relative zoom needs an origin and the daemon is built never
to know one — font_pref.rs is explicit that it "never learns metrics,
advances, or what resolves". Hardcoding 16.0 would put a pixel constant
on the daemon side; always sending a size would destroy the `None`
state for everyone who never zooms. A configured base is the only
option where the daemon still infers nothing, and the untouched path
stays byte-identical.
THREE THINGS REVIEW CAUGHT THAT REVISION 1 HAD WRONG.
Q#Z3 was not implementable as framed. `keymap_stack::Scope` is
Buffer | Mode | Global and carries no frontend identity, so "bind on
GPU frontends only" does not exist; and FrontendEvent has no
command-invocation variant, so the GPU cannot ask for a command by name
either. A global binding would capture the chord in the TUI and take
away the terminal's own zoom — the very thing the user is pressing it
for. Commands ship; the binding waits on capability-aware keymap
resolution, which is now a named follow-on rather than something
smuggled in here.
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 inside functions; zoom must apply with no user action,
which makes it this project's first eager state consumer. Restore lives
at the end of install_state_dirs — by definition the moment state
becomes readable, so it cannot be ordered wrongly and a future third
startup path gets it without knowing it had to ask.
Every size write clobbered the family. set_font replaces both fields
unconditionally, so { size = n } alone silently cleared a configured
family until restart.
BITTEN, THREE WAYS. Dropping family preservation fails 3 tests.
Reverting to the framing's own first parser `^(%d+)$` fails 4 including
the seam restore — it anchors to end-of-subject and rejects the
newline-terminated file the writer emits, which is the contradiction
review caught in the framing before it reached code. Hardcoding the
16.0 origin fails the base test.
Also recorded: a loaded crdt run failed two m6_1 PTY tests with
`stty -a output was: ""`. That is R4/R6's empty-content readiness
family, and it means the readiness-helper audit's scope is wider than
three wait_for_file copies under tests/ — src/process.rs's own tests
carry the shape. Undiagnosed, load-sensitive, green isolated and on a
quiet full run; a scope note for that lane, not a registry row, since
the registry judges red CI runs and these were local.
Verified: fmt, clippy, diff-check, --lib 1900/0, crdt 2085/0,
gui_zoom 13/13, journey 47/0, m4 150/0, gpu 221/0, full_grid 1/1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zooming a terminal with Ctrl +/- left the TUI showing the previous
frame through the new one. Q#FG1 = A, as approved.
THE RULE WAS ALREADY WRITTEN DOWN, ON A PRIVATE FIELD.
src/instance_render.rs:36 says remote frontends "must blank their local
buffer before applying the deltas" — the binding contract, in the one
place a consumer author will never look. The protocol type said only
that full_grid marks "the initial sync ... versus an incremental
frame": a label, from which no obligation follows. So FG-INV now lives
on InstanceMessage::CellDelta, where whoever writes the next frontend
reads it. A resync is a picture of the screen's INK, not of the screen.
The producer diffs against a blank grid, so a cell that should be blank
produces no span. src/frontend.rs then took `CellDelta { spans, .. }`
and discarded the flag. That was correct for exactly one frame — the
fresh-attach frame, which follows Frontend::new's Clear — and wrong for
every resize after, which follows nothing. A font-size change is the
worst case because the terminal reflows in place rather than dropping
content, so the maximum number of stale glyphs survive.
emit_cell_delta joins emit_span and emit_status_overlay as a pure
helper over a writer; apply_message routes through it. No struct
change, no generic parameter, no new pattern.
WHY SEVEN TESTS MISSED IT. Every one asserts the producer SETS the
flag; none asserted a consumer ACTS on it, and no runtime reader
existed workspace-wide. "Add a test for the flag" had already been
done and did not help. Handoff §5's enforcement-vs-documentation drift,
in a second register.
Three unit witnesses, each bitten independently. The empty-spans case
earns its own test rather than folding into the others: under the
plausible `spans.is_empty()` early return the ordering test still
PASSES and only that one fails — and an empty resync is exactly the
frame whose entire content is the blanking.
The PTY acceptance drives a real SIGWINCH, and its mark is anchored to
CONTENT rather than time. A time-based settle was written first and is
unusable: a settled pmacs screen emits per-frame bytes forever, so
"output stopped growing" never becomes true. Anchoring just past the
first painted byte excludes both startup clears by construction —
Frontend::new clears before any frame exists, and the first frame is
itself a resync whose clear precedes its own spans. Bitten against the
original defect: 34,831 bytes after the first painted frame, no CSI 2 J
anywhere in them.
What it does not prove, stated here rather than found in review: the
suites assert on raw bytes, with 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.
Verified: fmt, clippy, diff-check, --lib 1900/0, crdt 2085/0, m4 150/0,
gpu 221/0, and the grid-driving suites — full_grid_resync 1/1, vterm
1/2/3 9+9+5, m5_5 15, m5_8 5, bottom_panel_stage1 47.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 2 of `docs/macos-ci-signal-integrity-framing.md` revision 3
(acceptance 6–9). Two test races, both the same shape: the thing waited
for was weaker than the thing asserted, so the wait could return inside
the window the assertion needs closed.
R4 — `wait_for_file` returned as soon as `fs::read` succeeded, which
succeeds on a ZERO-BYTE FILE. The probe publishes with
`open(path,'wb').write(b'1')` and `open()` creates the file before
`write()` fills it, so the helper handed `[]` to a caller asserting
`== b"1"`. It now takes the expected bytes and waits while the file
holds a STRICT PREFIX of them — the states a write in flight can be
observed in — returning anything else immediately so the caller's
`assert_eq!` stays the discriminating assertion rather than becoming a
timeout inside a helper that does not know what was expected.
All four callers pass their expectation. `wait_for_published_file`, one
function away in the same suite, gated the real-TUI smoke's
`assert_eq!(…, b"1")` on the identical predicate and is fixed with it:
leaving it would have let R4 recur under a different selector, which the
registry would then have had to judge a new incident.
R2 — the USR1 fixture waited on `ProcessEventKind::Started`, emitted at
SPAWN, not when `/bin/sh` has parsed `trap '' USR1`. SIGUSR1's default
disposition is terminate, so a signal inside that window kills the
child. The child now publishes a marker AFTER the trap and the test
waits for that marker's CONTENT (the same zero-byte trap applies to a
shell's `>` redirection). `exec` replaces the forked `sleep`, so the
group holds exactly one process and the ignored disposition survives by
POSIX rather than by the shell's fork-suppression optimization — an
unstated dependency the old fixture had, since these signals are
group-directed and a forked `sleep` is an untrapped group member.
Four witnesses, each verified by REVERTING the fix and observing the
failure rather than by reasoning about it:
- `wait_for_file_does_not_return_a_zero_byte_readiness_file` fails
`left: []`, `right: [49]` — R4's two required fragments, verbatim;
- `wait_for_file_does_not_return_a_partial_write` fails on the torn read
a length check alone would admit;
- `wait_for_file_returns_divergent_content_rather_than_timing_out` fails
against an over-strict helper that waits for an exact match;
- `usr1_readiness_waits_for_the_trap_not_for_the_spawn` fails
`left: Some("SIGUSR1")`, `right: Some("SIGTERM")` with the readiness
wait removed. Its fixture sleeps before `trap` so the pre-trap window
is deliberate rather than load-dependent, and it proves survival by
the child's EXIT DISPOSITION rather than by an absence observed within
a window.
R1 is NOT touched — referred to the async-runtime lane (Q#MCI3), because
widening its budget would make it pass and measure nothing more. R3 is
NOT touched and remains UNRESOLVED, owned by the process-signal /
reap-ledger lanes.
`docs/ci-red-signatures.md` moves R2 and R4 to a "Retired rows" section
with their dispositions and adds the rule the file needed and lacked: a
red matching a retired row is a RECURRENCE that puts the retirement in
question, never a known flake. `docs/active-work.md` carries this lane
from its first commit rather than after review asks for it.
Repetition sets, not single runs: the two `--lib` process tests 15/15,
the whole `vterm_stage2_acceptance` suite 15/15 at default parallelism.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage 3 steps 3 and 4. Omitting `display` now resolves to the PANEL for
listview, compile and terminal; dired keeps `"current"`, passed
explicitly to the shared resolver. Per-adopter `select` per Q#BP12:
listview true, compile false (passive output must not steal document
focus), terminal true.
The census predicted 37 failures across 5 suites and the flip produced
exactly that — same suites, same per-suite counts. The measurement was a
prediction, not an estimate, which is what the inverted step order was
for. Final sweep: 3449 passed / 0 failed against a 3447 baseline, the
+2 being new pins.
THE CENSUS COUNTED FAILURES, NOT CAUSES. Thirteen listview failures had
ONE root cause: a panel is derived-hidden while frame geometry is
unknown, and listview_acceptance never declared any — it never needed to
while listview defaulted to the current window. One helper took it from
13 to 2. The same applied to m4 and vterm_stage2. Geometry is
authoritative state and a grid frontend's real frame size IS its
declaration; the panel suites have always said so.
THREE DEFECTS THE FLIP EXPOSED, each fixed rather than tested around:
1. The OUTLINE panel's `on_visit` used `pmacs.window.switch_buffer` —
the RAW switch, which replaces the buffer in the ACTIVE window. That
was harmless while the outline opened into a document window. Once
the panel became the default the active window WAS the outline panel,
so RET clobbered the panel with the source and left nothing for `M-,`
to return to. The references panel was migrated to `display_file`
when the arc landed; the outline was missed because nothing exercised
it from a panel until now. Q#BP11c names this exact corruption, and
both the outline and compile tests now assert `M-,` FOCUSES the
panel rather than cloning its buffer into the document — an
assertion the previous one could not distinguish.
2. `pmacs.compile._last` stored only `{cmdline, cwd}`, so a recompile
reached `start_run` with no `display` and took the new default. A
user who ran `compile.run{display="current"}` would be moved into a
panel the moment they pressed `g`. An opt-out that reverts on the
next recompile is not an opt-out; `display` is stored and replayed,
with nil kept as nil so an omitted value still resolves to the
default rather than freezing at the first run's resolution.
3. `opts.display` on a nil `opts` — my own regression, introduced by
fix 2 and caught by `journey_acceptance`, which is exactly what that
ratchet is for.
COMPILE'S CHORDS ARE NOW PANEL-LOCAL, and that is a contract rather than
an accidental reachability loss. Every compile chord is bound
`scope = "buffer"`, so with `select = false` none dispatch from the
document — `C-c C-k` included. `acc34` pins it, and pins that
`M-x compile.kill` still reaches the running slot from anywhere via its
`or compile_slot()` fallback. A global chord is a command-surface
decision and belongs in its own framing.
TEST CLASSIFICATION WAS PER TEST, NOT PER SUITE. Two neighbouring
compile tests land on opposite sides: acc15 (RET-visits-error,
jump-back) asserts the NEW default, while acc16 (n/p within compile
output) genuinely needs the buffer selected and says so. compile's
suite-wide helper opts out because ITS subject is compile-BUFFER
behaviour; the placement-subject tests use a second helper that takes
the default. Every opt-out states why. Nothing was mass-added to make a
suite green.
s1_12's two concerns are split as directed: it keeps its Q#GB18
name-keyed-identity bite with explicit `display = "current"`, isolating
the buffer-level `p.prev` skip rule, while a new `s3_1` pins the
side-window presentation chain — C → B → A → delete, ending at the
document with the wrapper collapsed. The mechanisms are complementary:
presentation history chains in the side slot; `p.prev` prevents
raw-switch and capability-fallback loops.
Verified: fmt, diff-check, clippy with and without crdt, --lib 1896,
--lib --features crdt 2081, pmacs-protocol 19, m4 149, required GPU 221,
and the full serialized sweep at 3449/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage 3 step 2 (Q#S3-1). DEFAULT-PRESERVING WITH ONE INTENTIONAL
NORMALIZATION — not "behaviour-preserving", which would be too broad a
claim. Every adopter keeps its current default, and the full serialized
suite is 3447 passed / 0 failed with ZERO suites differing from the
pre-change baseline. But invalid-input behaviour DID move, deliberately,
and that is pinned rather than asserted in prose.
Before this, FOUR adopters validated the same three-value vocabulary in
four places: Rust for the terminal, and hand-written Lua copies in
listview.lua, compile.lua and dired.lua, each carrying its own copy of
the error string. `parse_adopter_placement` read like the shared parser
its doc comment implied but had exactly one caller. Four copies of one
rule is how the next adopter gets it subtly wrong, and the next adopter
is DAP.
`resolve_adopter_display(operation, raw, default)` now owns exactly
three things: the vocabulary, the error text, and the default policy.
Reachable from Lua as the internal seam `pmacs.window._resolve_display`.
THE DEFAULT IS A PARAMETER, NOT A CONSTANT, and that is load-bearing
rather than stylistic. listview/compile/terminal will resolve omission
to the panel in step 3; DIRED MUST NOT, because
`pmacs.path.set_directory_handler` calls it with `{ dest = dest }` and
no `display` key at all — a flipped default would open `pmacs .` in a
bottom panel. Passing the default in makes dired's exemption visible at
its call site instead of hidden in a divergent copy.
TERMINAL'S `window` MUTUAL-EXCLUSION STAYS IN ITS OWN WRAPPER. Only the
terminal accepts a `window` id and only it must reject `window` combined
with `display = "panel"`. A helper pretending the four parsers were
identical would be its own defect.
THE NORMALIZATION, DECIDED AND PINNED. Terminal read
`get::<Option<String>>("display")?`, so a non-string value raised mlua's
TYPE error before reaching any custom message, while the Lua copies
stringified it into their own. Nothing pinned either behaviour — every
existing assertion passes an unknown STRING, which takes the same path
under both designs and therefore could not have caught a regression
here. The custom error wins because it names the legal vocabulary; the
value is rendered by TYPE ALONE (`unknown display (integer)`) so the
message cannot imply a string was passed.
Pinned at the terminal entry point in acc19 — the one adopter whose
behaviour changed — asserting the shared error AND that nothing is
created. The type SPELLING is deliberately not pinned: Lua 5.4 says
`integer` where LuaJIT has no integer subtype, so asserting either
literal would pass on one CI flavor and fail on the other. Verified
46/46 under both.
COMPILE NEEDED AN EXPLICIT OMISSION CAPTURE, and finding that out is
what this step is for. The resolver collapses omission into its default,
but compile's recompile gate distinguishes them: it fires on OMISSION
only, never on an explicit `display = "current"`, which is the
documented opt-out and must reach the raw switch even when the previous
run was panel-placed. Resolving first and testing `== "current"`
afterwards would have silently merged the two and broken the opt-out
with every test still green. `display_omitted` is captured before the
resolver call and the gate keys on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two parts, one intent: record the release, and fix the user-facing
surfaces it falsified.
ABSORPTION. v1.1.0 shipped and nothing recorded it. docs/active-work.md
had no distribution lane, the handoff anchor still named c5f7501, and
COHERENCE.md — a REQUIRED framing input — still asserted "zero release
machinery exists" and graded journey step 1 as source-build-only. Both
are now false, and a framing doc written against them would have been
written against a lie.
* §0 scorecard: step 1 Partial -> Works; §17 Missing -> Partial; the
journey row moves off "steps 1, 11 and 12 remain the thin end" to
name 11 and 12 only.
* §17 ground truth rewritten: what Stage 1 shipped, and the nine
things it explicitly did not.
* §20 Priority 8: "State: zero" -> Stage 1 shipped, with the blocker
it named ("every other priority's value is invisible until this one
exists") recorded as LIFTED. Its next increment is a DECISION about
channels/update/signing, not a queued plan — worth stating so nobody
treats Stage 2 as pre-approved.
* A distribution lane in active-work.md, rewritten-not-removed because
the arc is not done.
Five durable facts move to the handoff §1, each of which cost something
to learn:
* a release build can produce FIVE binaries and three must never ship;
layer-2 exclusion is load-bearing, demonstrated when target/release
still held all three after building only two;
* `env!("CARGO_PKG_VERSION")` expands in the crate being COMPILED, and
three correct tests could not fail while two crates shared a number;
* pin release runners, and assert the glibc floor from the binary
rather than trusting the pin;
* a tag pushed before its workflow reaches the default branch does
nothing, SILENTLY;
* verify from the downloaded artifact, with a negative control — the
1,576 `loro` strings mean nothing without the control build's zero.
STALENESS, found by reading the surfaces a new downloader hits first.
`pmacs --help` claimed the TUI was "currently the only frontend;
reserved for the M4 GUI rollout, where `pmacs` will default to the GUI".
That is not merely dated — it is false in a release that SHIPS the GPU
frontend as a second binary. Rewritten to say what -nw actually does
(name the default explicitly for scripts and wrappers), and --gpu now
states its two real preconditions: a `crdt` build, and pmacs-gpu either
beside the binary or on PATH. Both are things a downloader can get
wrong and neither was documented where they would look.
Also in main.rs: the TLS attach line said "activation in v0.2" and four
doc comments dated themselves "v0.1" or "M4+" while describing behaviour
that is still current. The behaviour claims were accurate; only the
version labels lied, so the labels are gone rather than the sentences
rewritten. One comment gained a correction it needed regardless —
FrontendChoice is IN-PROCESS dispatch and the GPU frontend is not a
value of it, which the old text implied it eventually would be.
README status block: v1.0.0 -> v1.1.0, protocol v20 -> v21, and it
pointed at docs/roadmap-2026-07.md for "current direction" — a file that
opens by calling itself a historical snapshot and redirecting to the
handoff. It now points at COHERENCE.md and the handoff, and mentions the
arcs that landed since it was last touched.
Verified: fmt, diff-check, clippy with and without crdt, --lib 1896,
--lib --features crdt 2081, pmacs-protocol 19, m4 149, required GPU 221,
and `pmacs --help` rendered and read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.github/workflows/ had exactly one workflow and it was test-only: no
release job, no artifact upload, no tags-to-binaries path. Installing
pmacs meant `git clone` plus knowing the feature-flag matrix.
COHERENCE.md §17 grades this "missing — zero release machinery exists";
this moves it to Partial and completes journey step 1.
Scope is one stage: binaries when a `v*` tag is pushed, attached to a
GitHub Release. Channels, rollback, update-in-place, signing, RHEL 9 and
Intel macOS are out of scope and named in the framing's §5.
WHAT SHIPS: pmacs and pmacs-gpu, both at 1.1.0, CRDT-enabled, co-located
in one archive, with SHA256SUMS. pmacs-protocol stays at 1.0.0 — it is
the wire crate and versions on its own schedule.
THE VERSION BUMP EXPOSED A REAL DEFECT, and it is the reason this PR
touches src/ at all. `InstanceIdentity::for_running_process` is defined
in pmacs-protocol and expanded `env!("CARGO_PKG_VERSION")` THERE. `env!`
expands in the crate being compiled, so the field documented as "Pmacs
version string" carried the PROTOCOL crate's version. That identity
reaches Lua as `pmacs.instance.identity()` and goes on the wire in
`Hello`, so a 1.1.0 release would have told every attached frontend it
was 1.0.0.
Nothing could have caught it earlier. Three tests assert
`id.pmacs_version == env!("CARGO_PKG_VERSION")` evaluated in the pmacs
crate — the correct assertion — but while both crates read 1.0.0 they
compared the same number reached by two different paths and COULD NOT
FAIL. Deciding to hold pmacs-protocol at 1.0.0 while moving pmacs is
what made them discriminating; all three failed on the bump. The version
is now a parameter so `env!` expands in the caller's crate. A test can
be correct and still prove nothing when the two things it compares are
equal for a reason unrelated to the code under test.
TWO LAYERS OF BINARY EXCLUSION, and layer 2 is load-bearing —
demonstrated, not argued. Cargo auto-discovers src/bin/*.rs, so a
release build can produce five binaries and three must never ship
(pmacs-audit is a contributor tool; pmacs_fake_lsp and pmacs_fake_mcp
are test fixtures). Layer 1 names explicit --bin targets. Layer 2 stages
an explicit asset list, and building this branch produced exactly the
case it guards: after building ONLY --bin pmacs and -p pmacs-gpu,
target/release still held all three forbidden binaries, left by an
earlier `cargo test --release`. Swatinem/rust-cache restores that kind
of directory in CI. An implementation trusting layer 1 and archiving the
directory would have published a fake language server in the first
release.
The three archive assertions are bite-verified: a smuggled
pmacs_fake_lsp, a missing pmacs-gpu, and a cleared executable bit are
each caught, with the honest archive passing.
THE GLIBC FLOOR IS ASSERTED, NOT TRUSTED. Pinning ubuntu-22.04 sets the
floor at 2.35 (Ubuntu 22.04, Debian 12 — NOT RHEL 9 at 2.34, which needs
a container or cross-build and is parked). But a pinned runner proves
nothing about the artifact, and the failure surfaces as a bare
`GLIBC_2.39 not found` on a user's machine with no clue which commit
caused it. The build reads versioned-symbol requirements out of the
binary and fails above the floor, so switching to ubuntu-latest fails in
CI instead of shipping. Bite-verified both directions on a glibc 2.44
host. Both runners are pinned; macos-latest would drift the minimum
supported macOS with no commit to point at.
Preflight runs before any build: the tag must match the root crate
version (stripping a prerelease suffix, so v1.1.0-rc.1 and v1.1.0 both
match 1.1.0), and the tagged commit must be an ancestor of main. Both
catch mistakes that are cheap now and expensive once a public URL
exists. The suite is not re-run — CI already tested the commit — but
nothing otherwise enforced that a tag points at a tested one.
Verified: fmt, diff-check, clippy with and without crdt, --lib 1896,
--lib --features crdt 2081, pmacs-protocol 19, m4 149, required GPU 221,
and the full serialized crdt sweep at 3,715 passed / 0 failed / 30
ignored — identical to the pre-change baseline, so the protocol
signature change broke nothing. Archive staging, contents, executable
bits and both --version outputs were exercised against a real release
build locally.
No release is cut by this PR. Per the framing's §7 the RC is tagged
after merge, from the merge SHA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo clippy --workspace --all-targets --features crdt -- -D warnings`
has never passed on main. The standing gate list runs clippy without
`crdt`, so these lints have never been enforced, and any CI job that
compiles the crdt targets would be red on arrival. This is framing §7
step 1: nothing else in the lane is testable until it lands.
Eight findings across four files, none behavioral:
src/daemon.rs useless_conversion (u64)
src/daemon.rs missing doc backticks
src/daemon.rs too_many_lines (112/100)
tests/auto_indent_crdt_acceptance.rs missing doc backticks
tests/bottom_panel_stage2b_gpu_acceptance too_many_lines (104/100)
tests/vterm_stage3_acceptance.rs too_many_lines (122/100)
tests/vterm_stage3_acceptance.rs too_many_lines (132/100)
tests/vterm_stage3_acceptance.rs redundant `continue`
--keep-going is what made this an inventory rather than a lower bound.
docs/active-work.md recorded seven findings at 74301d1 and correctly
warned they were "a lower bound, not an inventory" because clippy
abandons remaining targets once one fails. With --keep-going the set is
complete, and it differs from the ledger's in both directions: the
`unneeded mut` at src/daemon.rs:4965 is gone (fixed incidentally by
later work), a finding in bottom_panel_stage2b_gpu_acceptance.rs is new,
and every src/daemon.rs line number had moved. A stale lint inventory is
worse than none — it invites fixing lines that no longer exist.
The four too_many_lines findings are silenced with a reason rather than
refactored. Refactoring a test body to satisfy a lint that has never run
would be a behavioral change riding a CI-configuration lane, and the
codebase already has ~20 `#[allow(clippy::too_many_lines)]` sites, the
best of them carrying `reason =`. Each reason states why the scenario is
one test: the GPU acceptances exist specifically to prove a real
daemon, a real PTY and real wgpu fit together, which splitting would
hide.
The redundant `continue` needed care. Replacing it with `Err(_) => {}`
traded the lint for `single_match` — the match then destructured one
pattern. Rewritten as an edition-2024 let-chain, which drops both
without changing semantics: an unreadable message still falls through
to the next loop iteration.
Verified: clippy green with and without `crdt` (the second confirming
no regression to the enforced gate), fmt, diff-check, --lib --features
crdt 2081 passed, and the three touched suites green — vterm_stage3 at
9/9 in 4.34s rather than 0.17s, so a37 really ran rather than reporting
ok on a missing binary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements `docs/discovery-stage1-command-family-framing.md` (approved
at revision 6). `COHERENCE.md` §5 graded discoverability "substrate
without surface": the registries already carried descriptions, source
locations and reverse key lookup, and almost none of it was reachable.
Eleven commands under one `help.*` prefix, so typing `help` at M-x
surfaces the whole family. Nine are new; `editor.describe-command` and
`editor.describe-setting` are renamed in, with the old names retained
as forwarders so nothing documented breaks.
No Rust. Every command renders data `pmacs.describe.*`,
`pmacs.keymap.list()`, `pmacs.command.list()` and `pmacs.config.list()`
already return, and `describe-setting`'s completion source is a Lua
function via `CompletionSource::Custom`, which needed no binding work
either — correcting a comment in `default.lua` that claimed `source`
was a fixed Rust-side vocabulary.
`apropos` matches by substring, not fuzzy: `fuzzy_score` is
subsequence-based and descriptions are long sentences, so fuzzy would
match nearly every command.
Two disciplines the file keeps. Every command renders through the
public `pmacs.editor._show_help`, which buys one owner for the shared
`*help*` policy — reuse-by-name, wholesale replacement, `q`, and the
foreign-buffer hazard. It does NOT buy a one-site migration to
`src/help.rs`, which has no renderer for settings, lists or apropos; so
rendering is a named per-subject function, and the future Rust work is
enumerated per subject rather than discovered per call site.
The seam-counting pin earned its place immediately: the two renamed
commands were still calling the file-local `show_help_text`, so the
funnel was fiction for exactly the two commands that predate it. They
now call the public seam, with a comment saying why the local is not
used from the same file.
Moves `help` out of `welcome.lua` into the new `runtime/help.lua`,
which owns the family and loads after it so the index can read
`pmacs.welcome.entries`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
`EditorState::new` resolves two storage roots from the process
environment before it returns: the data root, which the bundled-package
materialization then WRITES into unconditionally (outside every `cfg`
guard), and the config root, from which `init.lua` is read.
The `#[cfg(not(test))]` guard on the second was written to stop the
crate's own unit tests picking up a developer's real `init.lua`. It does
exactly that and nothing more: `cfg(test)` is set only while compiling
the lib's own tests, so an integration test in `tests/` — compiled
without it — reads the real config and writes the real data root. On a
machine with a real `~/.config/pmacs/init.lua`, that is 11 deterministic
failures in `compile_mode_acceptance`, attributed to whatever branch is
checked out.
Tests cannot fix that themselves: `std::env::set_var` is `unsafe` and
this crate is `#![forbid(unsafe_code)]` — the same constraint that
produced `Installer::with_install_root_override`. So isolation arrives as
a parameter.
`BootstrapRoots` names the four storage roots (config, data, state,
cache). `ambient()` leaves every one `None` and every resolution goes to
the environment exactly as today, so production is unchanged.
`new_with_roots` and `open_with_roots` take it — both, because `open`
calls `Self::new()` internally and a constructor-only parameter would
leave every open-path test ambient. `install_state_dirs` consults it
too: it runs after construction, so resolving from the environment there
would reopen the hole the constructor closed.
The redirected branch changes WHICH directory is read, never WHETHER the
block runs. Config loading shares one conditional with
`set_init_complete()`, and `tests/m8_2_acceptance.rs:75` documents its
dependence on integration-test construction finishing init-complete.
`child_env()` translates the same value into the environment a spawned
`pmacs` needs. Five variables, not four: `PMACS_STATE_HOME` outranks
`XDG_STATE_HOME`, so a child given only the XDG four still resolves an
inherited state override — invisible on a machine that exports none.
The `src/editor.rs` comment claimed a protection it does not provide and
said nothing about the write above it; both are corrected in place. The
guard is deliberately NOT widened to cover integration tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Review round 1 on #205, two findings, both accepted.
The greeting was written straight into the registry without calling
`notify_buffer_edit`. The window's `TextView` had been indexed while
`*scratch*` was empty, and newlines are zero-width to a painter working
from a stale line index — so the first TUI frame collapsed the whole
three-line greeting onto row 0. Every buffer-text assertion passed
because the buffer content was correct; only the rendering was wrong.
The edit is now captured, the registry borrow released, and the core
notified.
The pin that would have caught it paints a real frame and asserts the
second line occupies its own row AND that row 0 does not contain it —
both directions, because a one-direction check passes when everything
collapses upward. Bitten by dropping the notify call: row 1 comes back
empty with row 0 holding the lot, and it is the only pin that fails.
Second: the project docs still described the arc as it was two PRs ago.
`COHERENCE.md` §20 called 1b-2 in flight and the welcome buffer
unstarted; its arc list said 1b-3 remained; and the ledger's journey
lane header still read "1b-2 PR OPEN" while the 1b-3 block carried a
mangled "Framing only; no code" line left by an earlier edit. All now
describe the PR-head state per §25.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Implements `docs/journey-stage1b3-welcome-framing.md` (approved at
revision 4, after three review rounds). The last of the 1b split.
`COHERENCE.md` §18 graded onboarding "missing entirely": no welcome, no
cheat sheet reachable from inside the editor, and `M-x` — the only door
in — discoverable only by already knowing about it. A fresh `pmacs` now
greets an untouched `*scratch*` with three lines naming `M-x` and four
real bindings, and `M-x help` renders a cheat sheet.
The startup seam is the substance. No constructor is the right hook:
`EditorState::open` calls `new` before resolving its target, the daemon
constructs one too, `init.lua` runs inside `new`, and desktop restore
happens later still. So `run()`'s terminal-free prefix is extracted into
`prepare_startup`, which `run` delegates to, and the greeting happens
there — after config, after attach dispatch resolves to local, and
after desktop restore. Extracting it is also what makes the wiring
testable: with the greeting called by hand from tests instead, deleting
the production call would leave every assertion green while shipping no
welcome.
Lua owns what is said, Rust owns when and where. `pmacs.welcome.entries`
is a structured list that both renders the text and drives the binding
checks — scraping the rendered prose would be ambiguous, since `C-c c`
is two chords and nothing in the text marks the boundary.
The greeting is deliberately NOT written through
`set_generated_contents`: that would lift read-only, discard history and
mark the buffer generated, all wrong for the buffer journey step 5
requires the user to type into immediately. It is left unmodified so it
does not look like unsaved work.
`M-x help` renders through `editor.describe-command`'s existing `*help*`
mechanism via a new `pmacs.editor._show_help` seam, rather than growing
a second help surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Review round 1, one finding, accepted.
"A failed escalation is never retried by anything" was false.
`shutdown()`'s force-kill loop iterates the reap ledger with **no**
`!entry.killed` guard, so it does re-kill an entry the escalation arm
gave up on. The accurate claim is that no later *tick* retries it —
`tick_reap_ledger`'s escalation is guarded by `!entry.killed` and never
fires again for that group.
The overclaim collapsed two failure modes that this lane exists to keep
distinct: a failed escalation leaks the group until editor exit, where
one more attempt is made, while a failed `shutdown()` force-kill leaks
it past exit with nothing left to try. Narrowed in the framing, the
handoff, the active-work ledger and the test commentary.
The corrected claim was asserted in three documents and pinned by
nothing, so it gets a pin: a failed escalation marks the entry, the
survivor stays alive across ticks, and `shutdown()` — with no fault
planned, so its force-kill really lands — still reaps it. Bitten by
adding the missing `!entry.killed` guard to that loop: the new pin
fails and the other five stay green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Found by bite-testing, not by review. With the in-drain seam reverted,
the pin still passed both content assertions and failed only the
consumed-plan check — meaning "LATE-MARKER is absent" was holding for
a reason unrelated to the probe.
`poll_one` sends SIGTERM to the whole group on leader exit, so the
untrapped descendant died before its 0.5s sleep finished. The late
marker never arrived on *either* path, which makes the absence
assertion vacuous: it would have stayed green with the collapse fixed.
The descendant now installs `trap '' TERM` behind `survivor_script`'s
readiness gate, so it survives the group TERM and writes its marker at
0.5s — well inside the 2s drain timeout the real path would run to.
Re-bitten: the reverted seam now fails on the LATE-MARKER assertion
itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Implements `docs/reap-ledger-silent-failures-framing.md` (revision 3).
Diagnosis only: every pin asserts CURRENT behaviour, including the
behaviour that is wrong. No disposition changes.
The ledger discards the result of four `kill(2)` calls, and each
discard has its own consequence:
- the liveness probe cannot tell ESRCH from any other errno, so an
unreachable group is deleted from the ledger exactly as a dead one
is, cancelling its escalation;
- the deadline escalation sets `killed = true` whether or not the
SIGKILL landed, so a failed one is never retried by anything;
- `shutdown()`'s force-kill does the same on the path written
specifically to stop a leak at editor exit;
- `final_drain_runtime`'s twin collapses every errno into "dead",
which quiesces the drain and cancels the readers.
None had an injection seam, so none was testable. This adds one, on
Q#PD4's terms: the injection replaces the *result only*, and every
branch, syscall and bookkeeping step downstream is production code.
The seam is directed per site, because `shutdown()` signals every
managed process before it reaches its ledger force-kill — a single
undirected slot would be eaten by the wrong call and the test would
pass while proving nothing. The persistent sites take a FIFO each,
since the shutdown-coupling pin needs a failed force-kill and a failed
subsequent probe pending at once. The in-drain site instead takes one
outcome that repeats for one whole drain: a one-shot is consumed by
the next 1ms probe and can never survive the 50ms window `quiesced`
requires.
Fixture state is per-supervisor, never global, and teardown asserts
every planned outcome was consumed — an unconsumed plan means the
fixture never reached the site, which would leave an absence
assertion vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Review round 5: one blocking, one major. Both documentation-only; the
runtime fixes from round 4 are unchanged.
**The pre-kill sample was overclaimed.** Acceptance 4 said the measured
group "describes the target that was attempted". It does not. `getpgid`
and `kill` remain separated by the same read-then-act window §1.5 is
built on, so the sample can be stale by the time the signal is
delivered. Moving it earlier removes a POST-HOC reading; it does not
make the reading contemporaneous. The criterion now says it records
pre-kill evidence about the attempted target, and adds an explicit
sentence forbidding any acceptance from claiming otherwise.
This is worth naming rather than quietly editing: the framing's whole
spine is that this telemetry establishes less than it appears to, and
round 4 fixed a real ordering defect and then inflated the fix's meaning
inside the document that governs it.
**`95897f7`'s commit message carries the same overclaim.** It is not
amended — force-pushing a branch under review to rewrite history would
hide the error rather than correct it. This message is the correction,
and the two read in sequence.
**A dead doc comment was documenting a constant.** The original
pre-Stage-B acceptance-1 block survived the rewrite and came to rest
above `const BASH`, so the file explained a string constant with the
sentence this entire lane exists to remove — "here they are asserted to
agree only because nothing has moved the terminal", describing a test
that no longer exists. Removed; only the BASH explanation remains.
Verified the overclaim is gone from the whole tree, not only the cited
line: no hits in `docs/` or `src/`.
Gates: 11 gates, 4,471 tests, zero failures.
Review round 4: three blocking, one major. All accepted.
**`measured_group` was sampled after the failure.** It ran inside
`signal_failure_report`, after the `kill` and after `observe_leader`,
while the framing and the function's own doc both said before. A
concurrent group change would have made the diagnostic report
post-failure state as evidence about the attempted target. It is now
sampled in `signal` before the kill and passed into the report, so the
field describes the target that was attempted rather than the state the
failure left behind.
**The Linux corroboration did not exercise the production lookup.** Its
helper read `portable_pty::process_group_leader` — the accessor this
lane stopped using on the signal path — so `pty_foreground_group` could
have fallen back on every call with every test still green. Forcing it
to always fall back demonstrates the hole exactly: the corroboration
fails, and the injected pin PASSES, because the injected tests supply
the group themselves and structurally cannot detect a broken lookup.
The helper now calls the production lookup, and the corroboration forces
only the kill so the report is built from a real terminal read. The
residual limitation is recorded rather than left to the green: on macOS
`pty_foreground_group` has no end-to-end coverage, because the platform
cannot produce the precondition.
**The framing did not update its own acceptance contract.** Revision 5
recorded Bet 1's falsification in the revision history and in the bet,
and left the normative criterion demanding the real-shell rewrite — the
"implementation quietly diverges from the contract" shape this project
already recorded as a lesson on #191/#188. Acceptance 1 now describes
the injected pin, 1a adds the corroboration and its macOS limitation,
and 4 states the sampling order. The ledger is synchronized: revision 6,
four commits, 4,471 tests, bash armed on Linux only.
**`TargetSource`'s doc had the wrong classification.** Two of the four
variants target the leader pid, not one, and the pid-versus-group split
does not line up with PTY-versus-pipe — which is why the fallback needed
its own variant rather than reusing `LeaderPid`.
Gates: 11 gates, 4,471 tests, zero failures.
CI falsified framing Bet 1. Both macOS legs reported
job control never moved the terminal off the leader
(leader=8542, foreground groups observed: [8542])
with the terminal staying on the leader for the entire 10s bounded wait.
Linux diverges reliably — 20/20 locally and green on both ubuntu legs —
so this is a platform difference rather than a flake, and rerunning past
it would have been wrong.
The framing named this outcome and prescribed the response, so that is
what ships rather than an improvised fix:
- The divergent case is pinned by INJECTING the foreground group at the
`signal_target` seam. Deterministic, runs on every platform. The
injection seam widens from failure-only to either outcome; the branch,
target choice, leader observation against the real child, and report
construction all remain production code.
- Verified still discriminating: the `leader_pid`-substitution mutation
fails it, `target=-1707909` against an expected `-1707910`. That was
the whole point of the original rewrite and it survives the fallback.
- The real shell is retained as corroboration in
`job_control_really_diverges_the_foreground_group`, Linux-only. It
skips on macOS by PLATFORM CHECK rather than by arming: the
precondition genuinely does not hold there, so running it would assert
a false claim about macOS instead of finding a bug.
- Framing revision 5 records the falsification and states exactly how
the injected pin is weaker — it proves the target is read from the
lookup rather than substituted from the leader; it does not by itself
prove any real shell produces that divergence.
`PMACS_REQUIRE_BASH` moves to Linux-only. The earlier reasoning for
arming both platforms — macOS is where the failures happen, so Linux-only
leaves it dark where it matters — was right about the diagnostic and
wrong about this test, which cannot produce its precondition on macOS at
all. Arming it there made a missing binary fatal for a test that can
never run. The measurement is recorded in ci.yml and the README so it is
not re-derived.
Gates on this tree: 11 gates, 4471 tests, zero failures.