docs: dired Stage 2 framing rev 6 — review round 5 (4 P1, 2 P2)

Revision 5 was reviewed and not approved. Round 5 has one theme, not
six: rev 5 changed the slice split and the ownership of a decision, and
the prose did not follow. Four of the six findings are that same defect
in different places. All six verified against the tree before acting;
two are understated by the review.

R1 (P1) — rev 5 was superseded by its own ledger entry and by a PR body
older still. active-work.md:507 records Q#DR25 as moved out of this lane
(dired's paint is one instance of a CLASS bug — the same
erroring-intercept-over-writable-rope idiom is in listview, compile and
search/grep, and no Lua caller anywhere sets read_only because there is
no Lua set_read_only), while the framing still called it mandatory and
assigned it to 2b, and the PR body still described revision 1's
two-slice plan. All three reconciled: Q#DR25 is withdrawn, §3.1 becomes
a handoff to the generated-buffer-immutability lane, and the PR body is
rewritten. Also corrected in the other direction: rev 5's claim that
Q#DR25 "closes dired's quarter" of the §14 gap was itself wrong — dired
was never a quarter of anything.

R2 (P1) — acceptance allocation contradicted the code split, and the
review undercounts by one. Items 23-24 test apply_resource_op and the
drain harvest (2a substrate) but sat under a 2b header; item 33 needs a
dired.lua subscriber while 2a is defined as containing no dired code.
23-24 moved to 2a; 33 moved to 2b, because 2a's whole review rationale
is that its diff contains no dired code. The cost is stated: between 2a
and 2b a directory rename leaves dired handles stale, which is the
status quo and not a regression. The undercount: that leaves 2a shipping
resource.renamed with no acceptance, so item 50 pins the hook contract.

R3 (P1) — TickOutcome could not carry deletions. Replaced the two ad-hoc
vectors with one ordered Vec<ResourceOp>, and PendingJob carries a
single Option<ResourceOp>. Ordered because a directory rename and a
delete beneath it can settle in one tick; one enum rather than two
Options because two admit a both-Some state that cannot occur, the
argument ResolvedTarget's own doc makes at editor_core.rs:100-102.

R4 (P1) — reconcile_delete stopped short, and the substrate is worse
than the review says. Right that removal is two phases; what it does not
say is that NO existing Rust path composes them. pmacs.buffer.kill
(mod.rs:5476-5491) does, and its doc says it is late-bound precisely to
redirect windows — but apply_resource_op uses remove_buffer_and_fire,
which is phase 2 without phase 1, and BufferRegistry::remove touches
only buffers and order. So an LSP-authored delete leaves a displaying
window pointing at a removed id: a third defect on that arm. §6 now
specifies both phases, the last-buffer and ConcurrentEdit refusals, and
items 51-53.

R5 (P2) — the "every consumer" claim was false for Lean. The deferral
was right and the summary was wrong; fixed the summary. Stage 2 supplies
the hook, Lean's state stays stale until its owner adopts it.

R6 (P2) — pre-three-slice file inventory. minibuffer.lua joins
src/editor.rs's include_str! sequence, so 2b touches a Rust file; "2b is
dired.lua plus one killring binding" was false. + and C retagged 2c.

The sweep the review asked for caught four more stale labels it had not
cited: §4's "C (copy, 2b)", §7's "(in 2b) a recursive delete", §8's op
sections, and §8's d/x line — which was wrong in BOTH halves, saying 2a
where it meant 2b and 2b where it meant 2c, a pure two-slice-era
leftover. Historical round sections keep their original labels, flagged
rather than retconned.

The ledger lane rides this PR and is updated to match.

Status unchanged: PROPOSED, needs explicit user approval. Do not merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
Levi Neuwirth 2026-07-28 17:59:54 -04:00
parent 259bfdb5ca
commit bc16716b86
2 changed files with 565 additions and 208 deletions

View File

@ -459,16 +459,26 @@ has **no branch and no framing yet**.
`FrontendView.fold_projection` to `true` for semantic frontends, which
Stage 2 deliberately left `false` (Q#FD21).
## dired Stage 2 framing lane — PR #171 AT REVISION 5, AWAITING APPROVAL
## dired Stage 2 framing lane — PR #171 AT REVISION 6, AWAITING APPROVAL
- Portable branch: `githubsucks/dired-stage2-framing` (head `e7f811b`);
- Portable branch: `githubsucks/dired-stage2-framing`;
worktree `../pmacs-dired-stage1`. **PR #171**, base `main`, integrated
up to `main` @ `ad41cf1`. Framing only —
`docs/dired-stage2-framing.md`, now **2,304 lines**, no runtime code.
- **Status: PROPOSED, never formally approved.** GitHub records no
formal review on it. The commit history embodies four revisions and
three review rounds; **that is not the same as approval**, and it must
not be read as one.
`docs/dired-stage2-framing.md`, now **~2,630 lines**, no runtime code.
This lane rides that PR.
- **Status: PROPOSED, never approved.** **Revision 5 was reviewed and
NOT approved** — six findings, four P1 — and revision 6 answers them.
The commit history embodies five revisions and four review rounds;
**that is not the same as approval**, and it must not be read as one.
- **Round 5's single theme, worth carrying because it will recur:** rev
5 changed the slice split and the ownership of a decision, and the
prose did not follow. Four of the six findings were that same defect
in different places — stale slice labels, an acceptance allocation
that contradicted the code split, and a PR body still describing
revision *1*'s two-slice plan. **When a cut changes, sweep for the
label, do not patch the instance.** Rev 6's own sweep found four more
the review had not cited, one of which was wrong in both halves
(`x`'s report tagged 2a, `remove_dir_all` tagged 2b).
- **Rev 4 was scouted at `c8ec8f3`, which is dired Stage 1's own merge
commit (#165)** — so dired Stage 1 and find-file (#162) were always
its base, not new arrivals. Eighteen PRs landed on top before the
@ -504,14 +514,32 @@ has **no branch and no framing yet**.
serialize-and-await batch contract the framing proposes.
- **The typed-edit chain does not reach dired** — verified rather than
assumed. Its lessons bind the framing's two *new* hooks instead.
- **Scope has MOVED OUT of this lane.** Rev 5 added Q#DR25 to adopt
`set_generated_contents` at the head of 2b, because dired's listing is
a generated buffer whose paint bypasses an intercept over a still
writable rope. That turned out to be a **class bug, not dired's**
the same idiom is in listview, compile and search/grep — so it is now
owned by the generated-buffer immutability lane. **#171 needs a
revision 6 that defers Q#DR25 to that lane** once its framing is
approved; do not implement Q#DR25 from this document.
- **Scope MOVED OUT of this lane, and rev 6 records the handoff.**
Rev 5 added Q#DR25 to adopt `set_generated_contents` at the head of
2b, because dired's listing is a generated buffer whose paint bypasses
an intercept over a still writable rope. That turned out to be a
**class bug, not dired's** — the same idiom is in listview, compile
and search/grep, and **no Lua caller anywhere sets `read_only`**
because there is no Lua `set_read_only` to call — so it is owned by
the generated-buffer immutability lane. **Done: rev 6 withdraws
Q#DR25**, and §3.1 hands that lane what this one's re-scout found,
including the trap that
`dired_buffer_is_read_only_and_round_trips_input` passes **either
way** and is therefore not coverage of the adoption. Do not implement
Q#DR25 from this document.
- **Two substrate facts rev 6 added that outlive this lane.** (1) Buffer
removal is **two phases** and **no existing Rust path composes them**:
`EditorCore::kill_buffer` (`src/editor_core.rs:4590`) does windows and
registry, `after_buffer_removed` (`src/lua_bindings/mod.rs:1602`) does
keymaps/config/folds/`on_removed`, and only the Lua binding
`pmacs.buffer.kill` (`mod.rs:5476-5491`) runs both — while
`apply_resource_op`'s delete uses `remove_buffer_and_fire`, which
skips phase 1, **so a window displaying the deleted buffer keeps a
removed id**. That is a third defect on that arm beside the missing
dirty check and the first-match lookup. (2) The drain outcome must
carry deletions as well as renames, **in settle order**, because a
directory rename and a delete beneath it can settle in the same tick
and reconciling them out of order targets the wrong path.
- Intended serial implementation once approved: **2a** rename/delete
reconciliation substrate with no dired UI, **2b** marks and
operations, **2c** mkdir/copy/recursive-delete primitives, then Stage

View File

@ -1,10 +1,11 @@
# Dired Stage 2 — marks and operations — framing
**Revision 5 — 2026-07-28. Status: PROPOSED — NOT APPROVED. This
**Revision 6 — 2026-07-28. Status: PROPOSED — NOT APPROVED. This
document has never received a formal framing approval, and it needs one
from the user before any implementation branch is cut.** Its four
commits embody three rounds of review findings; that is not the same as
approval, and GitHub records no review on PR #171.
from the user before any implementation branch is cut.** Its commits
embody four rounds of review findings; that is not the same as approval.
Revision 5 was reviewed and **not approved** — six findings, four P1.
§0's round-5 section says what each one changed.
**Ground truth: re-scouted 2026-07-28 against canonical `main` @
`6bee09d`** (`Merge pull request #184 from levineuwirth/bottom-panel-stage2b`).
@ -42,6 +43,125 @@ numbers drift and this document has now watched them drift twice.
## 0. Revision history
### Review round 5 (rev 5 → rev 6) — six findings, four P1, none approved
Round 5's theme is **one theme, not six**: rev 5 changed the slice split
and the ownership of a decision, and the prose did not follow. Four of
the six findings are that same defect in different places. Every cited
line was verified against the tree before being acted on; **all six
hold**, and two of them are *understated* — see R2 and R4.
- **R1 (P1) — rev 5 was superseded by its own ledger entry, and by a
PR body older still.** Verified: `docs/active-work.md:507` records
that Q#DR25 has **moved out of this lane** — dired's
`bypass_intercept`-over-a-writable-rope paint turned out to be a
**class** bug, the same idiom appearing in listview, `compile.lua` and
the search/grep panel, with **zero Lua callers anywhere setting
`read_only`** — and that a separate lane now owns it (branch
`generated-buffer-immutability`, worktree
`../pmacs-generated-immutability`, framing in progress). Meanwhile
rev 5 still called the adoption a fix "Stage 2 may not skip" (§1) and
assigned it to 2b (§10), and the **GitHub PR body** still described
revision *1*'s two-slice plan against `c8ec8f3`. Three sources, three
different stories. **Rev 6 reconciles all three:** §1 and §3.1 now
defer to the lane, §10's table drops the row, Q#DR25 is restated as a
deferral in §11 and §15, and the PR body is rewritten. *Dired's
adoption is not cancelled — it is owned elsewhere*, and this document
now says where rather than deleting the problem. **Correcting one
thing rev 5 got wrong in the other direction:** rev 5's §0.5 claimed
Q#DR25 "closes dired's quarter of that gap". That was already
arguable, and the class-bug finding settles it — dired was never a
quarter of anything; it was one instance of one idiom.
- **R2 (P1) — the acceptance allocation contradicted the code split,
and the review understates it by one.** Verified: items **2324** test
`apply_resource_op`'s delete arm and fire-and-forget `pmacs.fs.remove`
— both pure **2a** substrate — yet sat under a `**2b** — deletion
policy` header and inside 2b's `124` table range. Conversely item
**33** asserts an open dired buffer follows a directory rename, which
needs a `dired.lua` subscriber, while §10 and §16 both define 2a as
containing **no dired code at all**. Rev 6 **moves 2324 into 2a** and
**moves 33 into 2b**, and justifies the direction rather than leaving
both readings open (§13). **The review asked for a choice on 33 and
the choice is 2b**, because 2a's entire review rationale — §10's "with
no dired surface at all" and §16's "its diff contains no dired code" —
is what makes it reviewable as substrate rather than as a dired
feature, and admitting one dired subscriber to satisfy one acceptance
item would spend that. The cost is stated: between 2a and 2b a
directory rename leaves dired handles stale, which is **exactly the
status quo** and so is not a regression. **The undercount:** moving 33
out leaves 2a shipping `resource.renamed` with no acceptance on the
hook itself, so rev 6 adds **item 50** pinning the hook's own contract
(fires once per *successful* rename, with normalized absolute paths),
which is what 2b's subscriber attaches to.
- **R3 (P1) — `TickOutcome` could not carry what §6 asked of it.**
Verified: the struct as specified held `settled` and `renames` only,
while §6 required deletion to emerge "via the same `TickOutcome`". As
written `_tick` could not learn which settled `FsRemove` to reconcile
or which path to pass to `resource.deleted`. Rev 6 replaces the two
ad-hoc vectors with **one ordered `Vec<ResourceOp>`** and makes
`PendingJob` carry a single `Option<ResourceOp>` rather than rev 5's
`rename_paths` (§5). Ordering is the reason it is one vector and not
two: a directory rename and a delete beneath it can settle in the same
tick, and reconciling them out of order reconciles the wrong path.
A single enum field also refuses the impossible both-`Some` state, the
argument `ResolvedTarget`'s own doc makes at `src/editor_core.rs:100-102`.
- **R4 (P1) — `reconcile_delete` stopped short of the real removal
lifecycle, and the substrate is worse than the review says.** The
review is right that removal is two phases — `EditorCore::kill_buffer`
(`src/editor_core.rs:4590`) for window and side-window cleanup, and
`after_buffer_removed` (`src/lua_bindings/mod.rs:1602`) for keymaps,
buffer-local config, folds and `on_removed` callbacks. **What it does
not say is that no existing Rust path composes them, and the one
`apply_resource_op` uses is the incomplete one.** Verified:
`pmacs.buffer.kill` (`install_buffer_kill`, `mod.rs:5476-5491`) *does*
compose both, and its doc comment says it is late-bound precisely
because it "needs an `EditorCore` handle to redirect any windows
showing the doomed buffer before removal". But `apply_resource_op`'s
delete arm calls **`remove_buffer_and_fire`** (`mod.rs:1592`) =
`registry.remove` + `after_buffer_removed`, with **no window cleanup**
— and `BufferRegistry::remove` touches only `buffers` and `order`. So
**an LSP-authored delete leaves any window displaying that buffer
pointing at a removed id**, which is a *third* defect on that arm that
rev 5 never named, alongside the missing dirty check and the
first-match lookup. §6 now specifies both phases, the failure modes
(`ConcurrentEdit` on a mid-edit buffer, and the refusal to kill the
**last** remaining buffer), and acceptance items 5153.
- **R5 (P2) — the "every consumer" claim was false for Lean.** Verified
the contradiction: §1 listed `lean.lua`'s progress table among the
consumers reconciliation reaches, while §11 correctly said Stage 2
does not write that subscriber. **The deferral was right and the
summary was wrong**, which is the direction rev 6 fixes: Stage 2
supplies the hook; Lean's URI-keyed state stays stale until its owner
adopts it. Owner 6 remains in §5's census — it is still evidence that
`forget_uri` cannot be complete — it is just no longer claimed as
fixed.
- **R6 (P2) — pre-three-slice text in the file inventory.** Verified
both halves: §7 requires `builtin/runtime/minibuffer.lua` to join
`src/editor.rs`'s explicit `include_str!` load sequence (which is a
real edit to `src/editor.rs` — the sequence is ~30 entries at
`src/editor.rs:395-660`), contradicting §16's "2b is `dired.lua` plus
one killring binding"; and `+` and `C` were still tagged **2b** in §1's
table though §10 puts them in 2c.
**The sweep the review asked for caught four more instances of the same
defect**, none of them cited:
| Where | Said | Should say |
|---|---|---|
| §4, the set-based class list | "`C` (copy, **2b**)" | 2c |
| §7, the confirmation surface | "`x`, `D`, and (in **2b**) a recursive delete and an overwriting copy" | 2c |
| §8, `d`/`x` | "reported as such in **2a**; **2b**'s `remove_dir_all`" | **wrong twice**`x`'s report is a **2b** surface, and `remove_dir_all` is **2c** |
| §8, the op sections | "**`+`** (create directory, **2b**)" and "**`C`** (copy, **2b**)" | 2c |
The §8 line is the clearest survivor of the two-slice era: under rev 1's
plan "2a" was the mark layer and "2b" was the primitives, so a
sentence written then reads one slice off in *both* halves after the
three-way cut. All are corrected. The three historical round sections
keep their original labels, where "2b" means the old primitives slice —
flagged in the note at the head of round 1 rather than retconned.
---
### Re-scout round 4 (rev 4 → rev 5) — `c8ec8f3``6bee09d`, 153 commits
No reviewer produced these; a re-scout did. Rev 4's design survives, but
@ -70,8 +190,11 @@ its *internal* ones wrong). Every claim below was read on the tree at
(`src/buffer.rs:568-577`) without ever consulting the intercept chain.
Stage 2 writes that buffer on every mark, every unmark, every toggle,
and after every batch, so it multiplies the exposure rather than
inheriting it quietly. Rev 5 adopts the primitive as **Q#DR25** (§3,
§10) and states the three traps the handoff attaches to it.
inheriting it quietly. Rev 5 adopted the primitive as **Q#DR25**.
**Round 5 withdrew that** — the same idiom turned out to be in
`listview.lua`, `compile.lua` and the search/grep panel, so it is a
class bug with its own lane (R1). The re-scout finding stands; only
its owner changed. See §3.1.
- **N2 (shape change) — #182 (Journey Stage 1a) demoted dired to a
replaceable slot, and rewrote the function Stage 2's operations run
inside.** `resolve_target_buffer` gained a `ResolvedTarget::Directory`
@ -498,8 +621,13 @@ claim that only became true when #182 landed.
buffers" — and classifies dired, with listview, as **"the cheap
half"**, because both already write whole-buffer replaces and so need
none of the streaming variant the three appending buffers require.
Q#DR25 closes dired's quarter of that gap. It does **not** close the
other three, and this framing does not claim progress on them.
**Rev 5 claimed Q#DR25 closed "dired's quarter" of that gap; rev 6
withdraws both the decision and the claim** (R1). Dired was never a
quarter of anything — it is one instance of one idiom, and the missing
capability (no Lua `set_read_only`, so no Lua caller can assert
`read_only` at all) is shared by all four. **Stage 2 therefore makes
no progress on §14's generated-buffer gap, and no longer says it
does**; the `generated-buffer-immutability` lane owns it (§3.1).
Separately, §14's tree-primitive point is untouched: Stage 2 adds no
tree, and `i` (insert subdirectory) stays deferred (§11) precisely so
it can land on a shared primitive rather than inventing one.
@ -552,8 +680,8 @@ The Emacs dired working loop: select a set of files, then act on it.
| `R` | `dired.do-rename` | Rename the entry **at point** (§4) |
| `w` | `dired.copy-filename` | Copy the marked filenames to the kill ring |
| `M` | `dired.do-chmod` | Mode bits on the marked set; **refuses symlinks** (NEW — Q#DR19) |
| `+` | `dired.create-directory` | Create a subdirectory (**2b**) |
| `C` | `dired.do-copy` | Copy the marked set (or entry at point) (**2b**) |
| `+` | `dired.create-directory` | Create a subdirectory (**2c**) |
| `C` | `dired.do-copy` | Copy the marked set (or entry at point) (**2c**) |
`w` is carried forward from the parent's approved table; **`M` is new
scope** and needs explicit approval, since the parent listed neither it
@ -563,23 +691,31 @@ Two small public surfaces come with it, both because the operations have
nowhere to land otherwise: **`pmacs.buffer.set_name`** (Q#DR21) and
**`pmacs.killring.push`** (Q#DR22).
**And one correctness fix to Stage 1 that Stage 2 may not skip
(Q#DR25, new in rev 5): dired's listing becomes a genuinely immutable
generated buffer**, written through `pmacs.buffer.set_generated_contents`
rather than an erroring intercept over a writable rope. On `main` today
`M-x buffer.undo` empties a dired listing — no keybinding required — and
every mark, unmark, toggle and post-batch revert Stage 2 adds is another
write to that undo stack. §3 states the change; §10 places it at the
head of 2b.
**Not in Stage 2, and no longer this document's decision (Q#DR25,
withdrawn in rev 6 — R1): dired's listing becoming a genuinely
immutable generated buffer.** Rev 5 added it here as a fix "Stage 2 may
not skip". It is a real defect — `M-x buffer.undo` empties a dired
listing on `main` today — but it is **not dired's defect**: the same
erroring-intercept-over-a-writable-rope idiom is in `listview.lua`,
`compile.lua` and the search/grep panel, and **no Lua caller anywhere
sets `read_only`**. A class bug gets a class fix, so it now belongs to
the **generated-buffer immutability lane** (branch
`generated-buffer-immutability`). §3.1 records what that lane inherits
from this one; §11 and §15 carry the deferral. **Stage 2 must not
implement it**, and Stage 2's acceptance must not pin it.
Plus, invisibly: **renaming or deleting a path starts reconciling every
consumer that holds it** — buffer path *and* name, the fourteen
Plus, invisibly: **renaming or deleting a path starts reconciling the
consumers this stage can reach** — buffer path *and* name, the fourteen
URI-keyed LSP store families and the attached diagnostic view, dired's
own pathless handles, `lean.lua`'s URI-keyed progress table, and the
workspace-edit applier (§5, §6). That is a correctness fix to shared
substrate; it is the reason `R` is safe on a directory at all, and it
closes a path on which an LSP-authored delete currently destroys unsaved
work.
own pathless handles, and the workspace-edit applier (§5, §6) — **and
fires a hook so the ones it cannot reach are able to reconcile
themselves.** That distinction is load-bearing and rev 5 blurred it
(R5): `lean.lua`'s URI-keyed progress table is **not** reconciled by
this stage. Stage 2 supplies `resource.renamed`; Lean's state stays
stale until Lean's owner subscribes (§11). What Stage 2 *does* deliver
is a correctness fix to shared substrate: it is the reason `R` is safe
on a directory at all, and it closes a path on which an LSP-authored
delete currently destroys unsaved work.
Not in Stage 2: `wdired` (Stage 3), subdirectory insertion (`i`),
shell commands on marks (`!`), regexp marking (`% m`), and
@ -720,8 +856,10 @@ Rust-only. The nearest existing confirm is `autosave.lua:219-224`, a
(`:521-528`) — **no mark state; §3 adds it.** `render_entry` (`:334-349`)
hardcodes `BLANK_MARK` in column 0. `paint` (`:369-372`) is a wholesale
`buf:replace` with `bypass_intercept = true`; the read-only intercept
(`:509-511`) rejects everything else. **Both of those are what Q#DR25
replaces** (§3). `seat_cursor` (`:405-416`) re-seats by basename and
(`:509-511`) rejects everything else. **Both of those are what the
`generated-buffer-immutability` lane replaces** — rev 5 proposed to do
it here and rev 6 withdrew that (§3.1, R1); Stage 2 leaves `paint` and
the intercept exactly as Stage 1 built them. `seat_cursor` (`:405-416`) re-seats by basename and
carries the warning that `move_to_line` is **ambient** — every
post-`await` seat must first check `pmacs.window.buffer()`.
`entry_at_cursor` (`:381-385`) maps cursor line *n* to `entries[n]`,
@ -767,8 +905,9 @@ The three traps the handoff attaches to it, each verified here:
(`src/buffer.rs:559-566`) clears the v0.1 `undo`/`redo` stacks **and**,
under `#[cfg(feature = "crdt")]`, calls `crdt.clear_undo_history()`
because CRDT mode bypasses the v0.1 stacks entirely. Consequence for
§14: the `crdt`-featured `dired_acceptance` run is not a formality
here; it is the only run in which the CRDT half of Q#DR25 is live.
§14: whichever lane adopts the primitive, the `crdt`-featured run is
the only one in which that half is live — a default-feature run
exercises the v0.1 stacks alone.
There is deliberately **no** Lua `set_read_only`
(`src/lua_bindings/mod.rs:3074-3078` states the reason: it would let a
@ -854,9 +993,10 @@ Dired participates in neither `pmacs.typed_edit` nor
`pmacs.hook.add` at all. Nor does the generated-buffer write drag it in
`set_generated_contents`'s binding runs `notify_buffer_edit_to_windows`
and nothing else, and `EditorCore::notify_buffer_edit`
(`src/editor_core.rs:1814-1828`) runs no Lua hook. **So Q#DR25 does not
put dired writes on the chain**, which is the answer to the obvious
worry about N1 and N4 interacting.
(`src/editor_core.rs:1814-1828`) runs no Lua hook. **So adopting the
generated-buffer primitive would not put dired writes on the chain
either** — recorded for the lane that will do it (§3.1), since it is the
obvious worry about N1 and N4 interacting and the answer is no.
What *does* inherit from the chain is §5's two new hooks, because they
are new fan-outs. The relevant facts:
@ -915,44 +1055,72 @@ draw:
behavior, and the alternative silently converts flags into marks.
`U` clears both.
### 3.1 The listing becomes a genuinely immutable generated buffer (Q#DR25, new in rev 5)
### 3.1 The listing's write path — deferred to the generated-buffer lane (Q#DR25, withdrawn in rev 6)
Stage 1 protected the listing with an erroring intercept over a writable
rope, which was the best available idiom when #165 landed. #178 replaced
that idiom. Stage 2 adopts the replacement, in `dired.lua` only:
Rev 5 decided here that dired's `paint` would adopt
`pmacs.buffer.set_generated_contents`, dropping the erroring intercept.
**Round 5 withdrew that decision from this document** (R1), and the
reason is worth stating precisely because it changes what the fix *is*,
not merely who does it.
- `paint` becomes
`pmacs.buffer.set_generated_contents(handle.buf, render_text(handle))`,
dropping the `bypass_intercept` replace entirely.
- `claim_handle`'s `pmacs.buffer.add_intercept` (`dired.lua:509-511`)
is **removed**. It becomes redundant: the rope-level `read_only` the
primitive asserts refuses ordinary edits, undo, redo, and remote CRDT
imports alike, which is strictly more than the intercept refused.
- `pmacs.buffer.set_round_trip_input(buf, true)` (`:516`) **stays**, per
§2 — the two protections cover different copies and neither implies
the other.
The defect is real and rev 5 characterised it correctly: dired sets no
rope-level `read_only``claim_handle` installs an intercept and
`set_round_trip_input`, nothing more (`dired.lua:506-519`) — while
`Buffer::undo` reaches the rope through `ensure_writable`
(`src/buffer.rs:568-577`) without consulting the intercept chain, and
`M-x buffer.undo` (`builtin/commands/default.lua:179`) is reachable from
M-x with no binding. So a dired listing is emptiable today.
**Why this belongs in Stage 2 rather than a standalone fix.** It is a
three-line change to one file with no Rust at all, and it touches the
exact function every Stage 2 operation calls. Landing it separately
would mean two PRs racing on `paint`. It goes at the **head of 2b**
(§10), before the mark layer, so that every mark-layer acceptance
exercises the new write path rather than the old one. If the user would
rather see it land on its own — it is, after all, a live defect on a
step-3 journey surface — it detaches cleanly and 2b rebases onto it;
this framing states the preference, not a constraint.
**What rev 5 got wrong is the scope.** It framed this as dired's
quarter of a four-writer inventory. It is not a quarter of anything: the
same idiom — erroring intercept, `bypass_intercept` write, writable rope
— is in `listview.lua`, `compile.lua` and the search/grep panel, and
**no Lua caller anywhere sets `read_only`**, because there is
deliberately no Lua `set_read_only` to call
(`src/lua_bindings/mod.rs:3074-3078` says why). One idiom, one missing
capability, four instances. Fixing dired's instance inside a dired stage
would have produced a fourth bespoke adoption and left the shared
question — what a Lua-owned generated buffer is *supposed* to do —
unanswered for the fourth time.
**The acceptance trap, stated because it would otherwise be missed.**
`tests/dired_acceptance.rs`'s
`dired_buffer_is_read_only_and_round_trips_input` (`:969`) asserts
`status(&s).contains("read-only")`. `BufferError::ReadOnly` renders as
``buffer `{name}` (id {id:?}) is read-only`` (`src/buffer.rs:1794`), so
**that test passes both before and after the swap** — it has no bite
against Q#DR25 and must not be mistaken for coverage of it. The pin that
bites is a new one: **`M-x buffer.undo` on a dired listing leaves the
listing intact** (§13 item 48), which fails against `main` today.
**It is therefore owned by the `generated-buffer-immutability` lane**,
whose framing is in progress. Stage 2 does not implement it, does not
gate on it, and carries no acceptance for it.
---
**What that lane inherits from this one**, recorded here so the re-scout
is not repeated:
- The three obligations the primitive carries, all verified at
`6bee09d`: an intercept is not `read_only`; the returned `Edit` **must**
be fanned out (the Lua binding already does it,
`src/lua_bindings/mod.rs:3092`, releasing the registry borrow first);
and `clear_history` (`src/buffer.rs:559-566`) must clear **whichever**
history exists, since CRDT mode bypasses the v0.1 stacks and keeps its
own in loro's `UndoManager` — so any acceptance for this must run
under **both** default and `crdt` features or it exercises one half.
- Adoption does **not** replace `set_round_trip_input`. The protections
cover different copies: rope-level `read_only` refuses the op at the
daemon, while round-trip input stops a semantic frontend applying
optimistically to its **own mirror**, which a daemon-side refusal
arrives too late to prevent. `dired.lua:516` stays either way.
- **A trap for that lane's acceptance.**
`tests/dired_acceptance.rs:969`'s
`dired_buffer_is_read_only_and_round_trips_input` asserts
`status(&s).contains("read-only")`, and `BufferError::ReadOnly`
renders as ``buffer `{name}` (id {id:?}) is read-only``
(`src/buffer.rs:1794`). **That test passes both before and after the
swap**, so it is not coverage of the adoption and must not be counted
as such. The pin with bite is `M-x buffer.undo` leaving the listing
intact, which fails against `main` today.
**What Stage 2 owes it: nothing but non-interference.** Stage 2 changes
`paint`'s *callers* (every mark and batch repaints) but not `paint`
itself, so the two lanes touch `dired.lua` in different places. If the
immutability lane lands first, Stage 2b rebases onto a `paint` that
already writes through the primitive and needs no change; if Stage 2b
lands first, the lane adopts a `paint` with more callers and still needs
no change to them. **Neither ordering creates a conflict**, which is why
this is a deferral rather than a dependency.
## 4. Target sets: which operations are set-based (Q#DR13)
@ -961,7 +1129,7 @@ Operations fall into **three** classes, and the class is a property of
the command, not a special case:
**Set-based** — `D` (delete), `M` (chmod), `w` (copy filename), and `C`
(copy, 2b) target:
(copy, 2c) target:
> **the marked set, or — if nothing is marked — the entry at point.**
@ -1314,28 +1482,52 @@ tick's" renames, which — as the review notes — needs either a second
queue or a scan of every settled entry. Neither was named, and both are
the one-off side channel `COHERENCE.md` §9 objects to.
Instead, **`tick` returns a structured outcome**:
Instead, **`tick` returns a structured outcome**. Rev 5's shape carried
`settled` and `renames` only, which §6 then asked to also deliver
deletions — an impossible read (R3). Rev 6 fixes the shape:
```rust
/// A settled filesystem mutation, with the paths the worker consumed.
pub enum ResourceOp {
Rename { from: PathBuf, to: PathBuf },
Remove { path: PathBuf },
}
pub struct TickOutcome {
pub settled: Vec<JobId>,
pub renames: Vec<(PathBuf, PathBuf)>,
/// Successful resource mutations, in settle order.
pub resources: Vec<ResourceOp>,
}
```
Settle identity and rename metadata come out of **one** transaction, from
the loop at `:1074-1108` that already borrows `pending` and reads
`job.kind`. `renames` carries only jobs that settled
`PendingState::Complete` — a failed or cancelled rename reconciles
nothing. The ~17 in-crate `let _ = rt.tick();` call sites are unaffected;
`_tick` reads `.settled` for the Lua table it already builds and
`.renames` for the reconciliation.
**One ordered vector, not two fields.** A `renames` and a `deletes`
vector would lose the relative order of operations that settle in the
same tick, and that order is load-bearing: rename `dir``newdir` and
delete `dir/child.txt` can settle together, and reconciling the delete
first targets a path that no longer exists while reconciling it second
targets `newdir/child.txt`. Only one of those is right, and only an
ordered sequence can express which.
`PendingJob` still gains `rename_paths: Option<(PathBuf, PathBuf)>`,
since `dispatch_fs_rename` currently **moves** both paths into the worker
closure (`:871-879`) and nothing retains them. §0.5 states why that field
is the coherent choice over a side map, and that a general
`purpose`/`owner` should later subsume it.
`resources` carries **only** jobs that settled `PendingState::Complete`
— a failed or cancelled mutation reconciles nothing, and fires no hook.
Settle identity and resource metadata still come out of **one**
transaction, from the loop at `:1074-1108` that already borrows
`pending` and reads `job.kind`. The ~17 in-crate `let _ = rt.tick();`
call sites are unaffected; `_tick` reads `.settled` for the Lua table it
already builds and `.resources` for the reconciliation.
**`PendingJob` gains one field, `resource: Option<ResourceOp>`** —
rather than rev 5's `rename_paths` plus the separate remove-path §6
implied. `dispatch_fs_rename` **moves** both paths into the worker
closure (`:871-879`) and `dispatch_fs_remove` (`:893`) moves its one,
so nothing retains them either way. A single enum field rather than two
`Option`s is deliberate: two would admit a both-`Some` state that cannot
occur, which is exactly the argument `ResolvedTarget`'s own doc makes
against a shape whose "three states that cannot occur" every caller
would have to re-establish by hand (`src/editor_core.rs:100-102`).
§0.5 states why carrying this on the job at all is the coherent choice
over a side map, and that a general `purpose`/`owner` should later
subsume it.
### Additivity
@ -1370,13 +1562,67 @@ today**, silently, and a second buffer on the same path survives.
### One seam
**`EditorCore::reconcile_delete(path) -> DeleteReconcile { killed,
kept_modified }`**, symmetric with `reconcile_rename`:
kept_modified, refused }`**, symmetric with `reconcile_rename`:
- walks the **whole** registry by normalized equality **or
path-component prefix**, so descendants of a deleted directory are
included and a second buffer on one path is not missed;
- **kills unmodified** buffers;
- **keeps modified ones alive** and returns them, so a caller can report.
- **kills unmodified** buffers, through the full two-phase lifecycle
below;
- **keeps modified ones alive** and returns them, so a caller can report;
- returns anything it **could not** kill separately from what it kept
deliberately — the two are different events and collapsing them makes
a failure look like a policy decision.
#### "Kills" means two phases, and no existing Rust path does both (R4)
Rev 5 said "kills" and stopped. Buffer removal in this substrate is two
distinct phases, and the important part is that **the only place they
are composed is a Lua binding**:
| Phase | What it does | Where |
|---|---|---|
| 1. Core | Refuses unknown ids and **refuses to kill the last remaining buffer**; drops the id from `round_trip_buffers`; **closes** side windows showing it (Q#BP10a — closed, not redirected, or an unrelated buffer is stranded in the panel slot); redirects every other window to a fallback (`*scratch*` or any other buffer), resetting `text_view`, `cursor`, `selection`, `overlays`, `view_top` and `goal_col`; then `registry.remove` | `EditorCore::kill_buffer`, `src/editor_core.rs:4590` |
| 2. Lua-side | Clears buffer-scoped keymaps, buffer-local config, folds, and fires the registered `on_removed` callbacks | `after_buffer_removed`, `src/lua_bindings/mod.rs:1602` |
**`pmacs.buffer.kill` composes both** (`install_buffer_kill`,
`mod.rs:5476-5491`), and its doc comment says exactly why it has to be
late-bound: it "needs an `EditorCore` handle to redirect any windows
showing the doomed buffer before removal".
**`apply_resource_op`'s delete arm does not.** It calls
`remove_buffer_and_fire` (`mod.rs:1592`), which is `registry.remove` +
`after_buffer_removed`**phase 2 without phase 1**. And
`BufferRegistry::remove` touches only `self.buffers` and `self.order`;
nothing in it looks at windows. So **an LSP-authored delete leaves any
window displaying that buffer pointing at a removed id.** That is a
*third* defect on that arm, alongside the missing dirty check and the
raw first-match lookup, and rev 5 named neither it nor the phase split.
**So `reconcile_delete` composes the same pair `pmacs.buffer.kill`
composes**, for every id it kills, and `apply_resource_op`'s delete arm
is rerouted through it — which is what makes the window bug go away as a
side effect of the seam rather than as a separate patch. Note the
layering consequence: phase 2 lives in `lua_bindings` and needs `&Lua`,
so `reconcile_delete` returns the killed ids and its **caller** runs
phase 2 over them, exactly as `pmacs.buffer.kill` does. `EditorCore` does
not gain a Lua handle.
**Failure modes, all of which a batch must survive (§9):**
- **The last buffer cannot be killed.** `kill_buffer` returns
`"cannot kill the last remaining buffer"`. Deleting the file behind
the only open buffer therefore deletes the file and **keeps** the
buffer — reported, not silently ignored, and returned in `refused`.
- **A mid-edit buffer refuses removal.** `BufferRegistry::remove`
returns `RegistryError::ConcurrentEdit` when `editing_in_progress()`,
leaving the registry untouched. Phase 1 having already redirected
windows, a partial kill is possible here; the reconcile must treat a
phase-1 failure as "keep the buffer" and not run phase 2, or callbacks
fire for a buffer that still exists.
- **Neither failure aborts the reconciliation of other buffers.** A
directory delete reaching twelve descendants must not stop at the one
that is mid-edit.
**Both paths call it**: the drain harvest for `pmacs.fs.remove`, and
`apply_resource_op`'s delete arm, replacing its first-match lookup.
@ -1403,9 +1649,11 @@ dired.
Rev 2 left this ambiguous, and the two options really do produce
different primitive contracts. Rev 3 chooses the one symmetric with
rename: **`remove` is harvested in the drain**, via the same
`TickOutcome`, firing **`resource.deleted(path)`**. `PendingJob` retains
the path for `JobKind::FsRemove` exactly as for `FsRename`.
rename: **`remove` is harvested in the drain**, through the same
`TickOutcome.resources` sequence, firing **`resource.deleted(path)`**.
`PendingJob` carries the path for `JobKind::FsRemove` in the same
`Option<ResourceOp>` field it carries a rename's pair in (§5, R3) — one
field, one enum, so the two cannot both be set.
The reason is the same as Q#DR14's: a **fire-and-forget** `pmacs.fs.remove`
must reconcile too, and dired firing the hook itself after its own
@ -1473,13 +1721,20 @@ caller would still need the harvest. Named in §11.
## 7. Confirmation (Q#DR15)
Destructive operations confirm: `x`, `D`, and (in 2b) a recursive delete
Destructive operations confirm: `x`, `D`, and (in 2c) a recursive delete
and an overwriting copy. There is no helper to do it with (C4).
**Stage 2 adds `builtin/runtime/minibuffer.lua` defining
`pmacs.minibuffer.confirm { prompt, on_yes }`**, loaded before
`dired.lua` in `editor.rs`'s explicit sequence.
**That sequence is a real edit to `src/editor.rs`** — around thirty
`include_str!` entries at `src/editor.rs:395-660`, each naming its
chunk — so **2b touches a Rust file**, which §16 now says and rev 5 did
not (R6). Ordering is load-bearing, not cosmetic: `dired.lua` calls
`pmacs.minibuffer.confirm` at load-time-registered command bodies, so
the defining chunk must run first.
It takes **no completion source**, and that is the decision, not an
omission:
@ -1520,7 +1775,7 @@ crash-recovery prompt, which does not belong in a dired PR.
`D`-flagged basenames, applies §6's visited-path policy, confirms with the
count, then deletes serially (§9) via `pmacs.fs.remove`, which handles
files and empty directories (C5). A non-empty directory fails with the
kernel's `ENOTEMPTY`, **reported as such** in 2a; 2b's `remove_dir_all`
kernel's `ENOTEMPTY`, **reported as such** in 2b; 2c's `remove_dir_all`
plus `dired.recursive-deletes` addresses it. Then revert once.
**`D` (delete now).** Same deletion path and same §6 policy, targeting
@ -1577,10 +1832,10 @@ the §4 set.
> approved text. A refusal is reported per entry and does not abort the
> batch.
**`+` (create directory, 2b).** Prompts for a name, no source, resolved
**`+` (create directory, 2c).** Prompts for a name, no source, resolved
against `handle.path`. `opts.parents` for `create_dir_all`.
**`C` (copy, 2b) — full command flow (F7).** Targets the §4 set.
**`C` (copy, 2c) — full command flow (F7).** Targets the §4 set.
1. **Destination prompt.** One source prompts for a destination path;
**several sources require an existing directory** and the command
@ -1652,21 +1907,25 @@ called it, and the three rounds of findings on the reconciliation half are
the evidence — every one of G1, G3, H1, H2, and H3 was about the
transaction, not about marks.
**Rev 5 keeps the three-PR cut unchanged** and adds Q#DR25 to the head
of 2b. The cut was re-examined against the re-scout and still holds: 2a
is still substrate-only, 2b is still the dired surface, 2c is still the
three additive primitives, and Stage 3 is still wdired. Nothing that
landed in the 153 commits moves work across those lines — #178 lands
inside 2b (it is dired-local), #182 changed the *shape* of dired's entry
point but added no Stage 2 work, and #179/#181 added none.
**Rev 6 keeps the three-PR cut unchanged.** The cut was re-examined
against the round-4 re-scout and again after round 5, and still holds:
2a is substrate-only, 2b is the dired surface, 2c is the three additive
primitives, and Stage 3 is wdired. Nothing that landed in the 153
commits moves work across those lines — #182 changed the *shape* of
dired's entry point but added no Stage 2 work, and #179/#181 added none.
**Q#DR25 has left the table entirely** (R1): it is the
`generated-buffer-immutability` lane's, not 2b's (§3.1). And the
acceptance row is corrected (R2) — rev 5's allocation put two
substrate items in 2b and one dired item in 2a.
| | **2a — reconciliation** | **2b — marks and operations** | **2c — new primitives** |
|---|---|---|---|
| User-visible surface | **none** | `m u U t d x D R w M`, plus a listing that survives `M-x buffer.undo` | `+ C`, recursive delete |
| Rust | `reconcile_rename`, `reconcile_delete`, `TickOutcome`, `PendingJob` paths, `forget_uri` (14 stores + `documents` + route purge + URI-scoped drain), `View::rename_resource` + the window sweep, `apply_resource_op` (rename **and** delete arms), `apply_workspace_edit` origin, `pmacs.buffer.set_name` | `pmacs.killring.push` | `mkdir`, `copy`, `remove_dir_all`; `JobKind` 12 → 15 |
| Lua | the two hook subscribers in `lsp.lua` | **Q#DR25 (`paint` → `set_generated_contents`, drop the intercept)**, then all of `dired.lua`'s mark/op layer, plus `minibuffer.lua` | two ops |
| User-visible surface | **none** | `m u U t d x D R w M` | `+ C`, recursive delete |
| Rust | `reconcile_rename`, `reconcile_delete` (both kill phases), `ResourceOp` + `TickOutcome`, `PendingJob.resource`, `forget_uri` (14 stores + `documents` + route purge + URI-scoped drain), `View::rename_resource` + the window sweep, `apply_resource_op` (rename **and** delete arms), `apply_workspace_edit` origin, `pmacs.buffer.set_name` | `pmacs.killring.push` | `mkdir`, `copy`, `remove_dir_all`; `JobKind` 12 → 15 |
| Lua | the two hook subscribers in `lsp.lua` | all of `dired.lua`'s mark/op layer, **including its `resource.renamed` subscriber**, plus `minibuffer.lua` | two ops |
| Other files | — | **`src/editor.rs`**, to add `minibuffer.lua` to the explicit load sequence (R6) | — |
| Config keys | none | none | `dired.recursive-deletes` |
| Acceptance | items 2538 | 124, 3941, **4849** | 4247 |
| Acceptance | 2338, **5053** | 122, 3941 | 4247 |
**Why 2a first, with no dired surface at all.** It is a self-contained
substrate correctness fix that stands on its own merits: it closes a path
@ -1705,7 +1964,7 @@ that deserve an undivided reviewer.
candidate fix is a settled-entry sweep with a reap policy, which is a
decision about handle lifetime, not a patch.
- **A general `purpose`/`owner` field on `PendingJob`**, per
`COHERENCE.md` §9, which should subsume §5's `rename_paths`.
`COHERENCE.md` §9, which should subsume §5's `resource` field.
- **Migrating `autosave.lua` to `pmacs.minibuffer.confirm`** (§7).
- **Multi-file `R` into a target directory**, and `%`-regexp marking —
both need a target/pattern concept Stage 2 does not build. This is why
@ -1752,18 +2011,22 @@ that deserve an undivided reviewer.
claim of this framing; it is a claim of the tree.
- **Recursive copy** — 2c refuses directory sources; a real `copy -r`
primitive is separate.
- **The other three non-adopters of `set_generated_contents`** (new in
rev 5). Q#DR25 closes dired's quarter of the handoff §4 / `COHERENCE.md`
§14 inventory. The remaining three — listview panels
(`builtin/runtime/listview.lua:60-61`), `compile.lua`'s `ensure_slot`
serving `*compilation*` **and** `*shell-command*`, and the independent
`*search-results*` panel (`builtin/commands/default.lua:869`) — stay
emptiable. Listview is the other cheap half (whole-buffer replace);
the three appending writers need a **streaming variant of the
primitive that does not exist**, which is why this is a lane and not a
rider. Note the handoff's warning: `ensure_slot` does **not** cover
`*search-results*`, and `*workers*`/`*help*`/`*buffer-list*` are
generated but do not use this idiom, so they are not in scope either.
- **The whole generated-buffer immutability class, including dired's
instance** (Q#DR25, withdrawn from this document in rev 6 — R1).
Dired's `paint` writes through `bypass_intercept` over a writable
rope behind an erroring intercept, so `M-x buffer.undo` empties a
listing. So do `listview.lua`, `compile.lua`'s `ensure_slot`
(`*compilation*` **and** `*shell-command*`) and the independent
`*search-results*` panel — one idiom, four instances, and **no Lua
caller anywhere sets `read_only`** because there is deliberately no
Lua `set_read_only`. Owned by the **`generated-buffer-immutability`
lane**; §3.1 records what that lane inherits from this re-scout, and
why neither landing order conflicts with Stage 2b. Note the two
sub-shapes that lane must reconcile and this one did not have to:
dired and listview write **whole-buffer replaces** and are the cheap
half, while `*compilation*`, `*shell-command*` and `*search-results*`
**append** per batch and need a streaming variant of the primitive
that does not exist.
- **Two persisted path owners, named not fixed** (rev 5, W5).
`saveplace.lua` keys its places file by path (`load_places`, `:31-45`;
`restore_active`, `:69-79`) and `recentf.lua` keeps a path MRU list
@ -1776,10 +2039,14 @@ that deserve an undivided reviewer.
transaction to rewrite persisted state, which is a persistence-arc
question. A `resource.renamed` subscriber in each is the cheap fix
whenever someone wants it.
- **`lean.lua`'s `M.file_progress` is not re-rooted** (rev 5, owner 6).
Stage 2 supplies the hook that makes a subscriber possible and does
not write one — Lean's arc owns that file, and Stage 5's goal view is
the consumer that would notice.
- **`lean.lua`'s `M.file_progress` is not re-rooted** (rev 5, owner 6;
reaffirmed in rev 6, R5). Stage 2 supplies the hook that makes a
subscriber possible and **does not write one** — Lean's arc owns that
file, and Stage 5's goal view is the consumer that would notice.
Round 5 found §1 claiming the opposite; **this deferral was the
correct half and §1 was corrected to match it**, not the other way
round. Until Lean subscribes, a rename leaves its progress entry under
the old URI, and nothing in Stage 2 detects that.
---
@ -1815,11 +2082,14 @@ that deserve an undivided reviewer.
- **B5.** `describe_key_identifies_every_default_binding` stays green
without further surgery — #165 already taught it per-binding mode
context, and Stage 2 only adds more bindings in the same mode.
- **B6** *(new in rev 5, N1)*. Q#DR25 needs **no Rust**: the primitive,
its Lua binding, and the fan-out all exist
(`src/buffer.rs:545`, `src/lua_bindings/mod.rs:3079-3095`). Falsified
if adopting it requires a new binding, a `set_read_only` exposure, or
any change outside `builtin/runtime/dired.lua`. *Not obvious in one
- **B6** *(added in rev 5; **retired** in rev 6, R1 — it was a bet
about Q#DR25, which is no longer this document's.* Handed to §3.1 for
the `generated-buffer-immutability` lane, where it is worth keeping:
the primitive, its Lua binding and the fan-out all exist
(`src/buffer.rs:545`, `src/lua_bindings/mod.rs:3079-3095`), so
dired's adoption should need no Rust — falsified if it requires a new
binding, a `set_read_only` exposure, or any change outside
`builtin/runtime/dired.lua`. *Not obvious in one
respect:* dropping the intercept removes the only thing that currently
produces dired's refusal **message**, so if any Stage 1 acceptance
depends on the intercept's exact wording rather than on the substring
@ -1900,6 +2170,11 @@ that deserve an undivided reviewer.
21. Deleting a directory kills buffers on its **descendants**.
22. An open dired handle on a deleted directory is closed
(`resource.deleted`).
*(Items 2324 moved to 2a in rev 6 — R2. They exercise
`apply_resource_op` and the drain harvest, neither of which 2b builds.)*
**2a — reconciliation (§5, §6)**
23. **`apply_resource_op`'s delete** no longer kills a **modified**
buffer, and now reaches **descendants** and a **second buffer on the
same path** (G4 — today it is raw-path first-match with no modified
@ -1908,8 +2183,6 @@ that deserve an undivided reviewer.
the handle still kills the unmodified buffer, which is what makes the
drain harvest the right seam rather than dired firing the hook.
**2a — reconciliation (§5, §6)**
25. **No-await rename**: dispatch `pmacs.fs.rename`, never take the
result, pump — the open buffer's path has moved. *(Fails if the
reconciliation lives at result-consumption.)*
@ -1939,10 +2212,6 @@ that deserve an undivided reviewer.
32. A rename **across project roots** re-runs `ensure_server` and the
buffer ends up attached to a **different** server; a same-root rename
reuses the existing one (#161's affinity key).
33. **An open dired buffer on the renamed directory** follows it: its
`handle.path`, **its buffer name** (`*dired:<new path>*`), and
`handle_for_path` dedup under the new path all move together (G2 —
asserting `handle.path` alone would pass with the name still stale).
34. **The workspace-edit origin**: renaming the *active* file through
the full `apply_workspace_edit` path leaves **no phantom empty
buffer** at the obsolete path, and the user is returned to the
@ -1988,32 +2257,42 @@ that deserve an undivided reviewer.
47. Recursive delete happens only with `dired.recursive-deletes` enabled
**and** a confirm; disabled, the non-empty directory still fails.
**2b — the generated-buffer invariant (§3.1, Q#DR25, new in rev 5)**
**2b — the dired subscriber (moved from 2a in rev 6, R2)**
48. **`M-x buffer.undo` on a dired listing leaves the listing intact.**
Driven through `pmacs.command.invoke` (or the M-x path), not through
a chord — the command is reachable with no binding, which is the
whole point. *(This is the pin with bite: it fails against `main`
today, where the listing is emptied.)* Its sibling asserts **redo**
is equally refused, since `Buffer::redo` takes the same
`ensure_writable` path.
49. **A repaint after the swap still reaches a displaying window.** Mark
an entry in a dired buffer shown in **two** windows and assert the
mark column changed in **both** — the fan-out obligation the
handoff attaches to the primitive. *(An implementation that called
`Buffer::set_generated_contents` from Rust and swallowed the `Edit`
would paint stale ranges; the Lua binding discharges this at
`mod.rs:3092`, and this pins that the Lua path is the one used.)*
Run under **both** default and `crdt` features: `clear_history`
clears loro's `UndoManager` only under `crdt`, so the default run
does not exercise that half at all.
33. **An open dired buffer on the renamed directory** follows it: its
`handle.path`, **its buffer name** (`*dired:<new path>*`), and
`handle_for_path` dedup under the new path all move together (G2 —
asserting `handle.path` alone would pass with the name still stale).
*Lives in 2b because the subscriber it tests lives in `dired.lua`,
and 2a ships no dired code (§10, §16). 2a pins the hook it attaches
to instead — item 50.*
Note explicitly what does **not** count as coverage of Q#DR25:
`dired_acceptance.rs`'s existing
`dired_buffer_is_read_only_and_round_trips_input` (`:969`) passes both
before and after the swap, because `BufferError::ReadOnly` renders with
the substring `is read-only` and the test asserts
`status(&s).contains("read-only")` (§3.1).
**2a — the hook and the removal lifecycle (new in rev 6)**
50. **`resource.renamed` fires exactly once per successful rename**,
with `(old, new)` as **normalized absolute** paths, and **does not
fire** for a rename that failed or was cancelled. *(R2 — without
this, moving item 33 to 2b would leave 2a shipping a hook with no
acceptance at all. The normalization half matters because
`ResolvedTarget::Directory`'s doc warns that normalization does not
happen on every path into the core, so a path-keyed subscriber
needs the canonical form.)* The symmetric assertion for
`resource.deleted` accompanies it.
51. **A killed buffer completes BOTH removal phases** (R4): after a
delete reconciles, an `on_removed` callback registered for that
buffer **has fired**, and its buffer-local keymap entries and folds
are gone. *(Fails against an implementation that calls only
`EditorCore::kill_buffer`, which does no phase-2 cleanup.)*
52. **A window displaying the deleted buffer is redirected, not left
dangling** (R4): with the buffer shown in a window, deleting the
file leaves that window on a valid fallback buffer, and **no window
holds a removed id**. *(Fails against `remove_buffer_and_fire`,
which is what `apply_resource_op` uses today — it does no window
cleanup at all.)*
53. **The last-buffer and mid-edit refusals are reported, not silent**
(R4): deleting the file behind the **only** open buffer keeps the
buffer and says so; and a delete reaching a directory of buffers
where one refuses removal still reconciles the rest.
**Bite obligations.** Each of these must fail against a stated mutation:
@ -2031,15 +2310,27 @@ the substring `is read-only` and the test asserts
| 34 | the applier restoring by path instead of by buffer handle |
| 39 | a completion source added to `confirm` |
| 40 | the batch changed to dispatch-all-then-await |
| 48 | `paint` reverted to `bypass_intercept` over a writable rope (i.e. `main` today) |
| 49 | the `Edit` returned by `set_generated_contents` swallowed instead of fanned out |
| 50 | the hook fired for a **failed** rename, or fired with the un-normalized path |
| 51 | `reconcile_delete` calling only `EditorCore::kill_buffer`, so phase 2 never runs |
| 52 | `reconcile_delete` calling only `remove_buffer_and_fire`, so windows keep a removed id (this is `apply_resource_op` today) |
*(Rev 5's items 4849 and their bites left with Q#DR25 — R1. They belong
to the `generated-buffer-immutability` lane, and §3.1 hands over the one
that matters: `M-x buffer.undo` leaving the listing intact is the pin
with bite, and the existing `dired_buffer_is_read_only_and_round_trips_input`
is **not** coverage because it passes either way.)*
Note that **51 and 52 are a matched pair, and neither alone is
sufficient** — each existing removal path passes one and fails the
other, which is exactly why the framing had to name both phases (R4).
`dired.lua` is an existing file now, so `scripts/bite`'s
swap-over-`git show` mode applies — but per #165's lesson, **commit
before biting**. Items 30, 33, and 34 came from round 2 and item 31 from
round 3; each is a case where the previous revision's design would have
passed a weaker test. Note that **item 20 has no bite for the interval it
cannot close** (H1) — only for the check it does make.
before biting**. Items 30, 33, and 34 came from round 2, item 31 from
round 3, and items 5053 from round 5; each is a case where the previous
revision's design would have passed a weaker test. Note that **item 20
has no bite for the interval it cannot close** (H1) — only for the check
it does make.
## 14. Gates (per PR)
@ -2063,26 +2354,29 @@ this stage (N3):**
by its own declaration, seven of its rows assert on dired, and its
step-3 and step-5 rows run *through* the surface Stage 2 modifies.
**Assert the count, not just the colour** — a row silently dropped is
exactly what the ratchet exists to prevent, and Q#DR25 changes how
dired's buffer is written underneath those rows.
exactly what the ratchet exists to prevent, and 2b changes the
contents those rows read.
- **`gpu_invocation_acceptance`**, which is where #183 put the GPU
journey row
(`public_gpu_directory_target_reaches_dired_and_leaves_the_daemon_usable`).
It drives a **real daemon child process**, so it is in the blast
radius of any change to how a dired buffer is written or protected —
Q#DR25 makes the listing rope-level `read_only`, and a daemon-side
refusal is exactly what that test's frontend would see. Note this row
radius of any change to how a dired buffer is written or protected.
That makes it a **required gate for the
`generated-buffer-immutability` lane too**, which will make the
listing rope-level `read_only` — a daemon-side refusal is exactly what
this test's frontend would see. Note this row
is **not** in `journey_acceptance.rs`; running only that file leaves
the GPU half of the journey unpinned.
Also add **`typed_edit_chain_acceptance` (13 tests)** to 2b's list, not
because dired joins the chain — it does not (§2, N4) — but because
Q#DR25 changes a `buffer.after-edit`-adjacent write path and the chain
is the tree's most order-sensitive consumer of edits.
*(Rev 5 also added `typed_edit_chain_acceptance` to 2b's list on
Q#DR25's account. With Q#DR25 gone (R1) that reason goes with it — dired
joins neither the chain nor `buffer.after-edit` (§2, N4), and 2b changes
no write path. It is not a 2b gate.)*
2b's `dired_acceptance` run is expected to **grow** by items 4849 and
otherwise hold at its current **25**; a *drop* there means Q#DR25 broke
a Stage 1 pin rather than superseding one, and the two are not the same.
**2a's dired-facing gate is `dired_acceptance` at an unchanged 25**, not
a grown one: 2a ships no dired code, so a moved count there means it
touched something it should not have. **2b's** is expected to grow by the
mark, operation and subscriber items.
---
@ -2216,24 +2510,47 @@ a Stage 1 pin rather than superseding one, and the two are not the same.
non-region text: push the entry, mirror to the OS clipboard, and
**break the kill chain**. `push_entry` is private and `copy()` requires
a region, so `w` has no surface without it. (§8)
- **Q#DR25** *(new in rev 5, N1)* **Dired's listing becomes a genuinely
immutable generated buffer.** `paint` writes through
`pmacs.buffer.set_generated_contents` instead of a
`bypass_intercept` `buf:replace`, and `claim_handle`'s erroring
intercept is **removed** as redundant — rope-level `read_only` refuses
strictly more (ordinary edits, **undo, redo**, and remote CRDT
imports). `set_round_trip_input` **stays**: the two protections cover
different copies, and a daemon-side refusal arrives after a semantic
frontend has already painted its own mirror. This is a correctness fix
to **Stage 1**, not new Stage 2 surface: on `main` today `M-x
buffer.undo` empties a dired listing, and dired is now a **step-3
journey surface** (§0.5). It lands at the head of 2b because it is
dired-local, needs no Rust, and touches the one function every Stage 2
operation calls — but it detaches cleanly if the user prefers it
standalone. The fan-out obligation is discharged by the existing Lua
binding (`mod.rs:3092`); the history-clearing obligation covers the
CRDT `UndoManager` too, which is why the `crdt` acceptance run is
load-bearing rather than routine. (§3.1, §10, §13 items 4849)
- **Q#DR25** *(added in rev 5; **withdrawn from this document** in rev
6, R1)* Dired's listing becoming a genuinely immutable generated
buffer is **not Stage 2's decision to make**. The defect is real —
`M-x buffer.undo` empties a dired listing on `main` today — but it is
a **class** defect, not dired's: the same erroring-intercept-over-a-
writable-rope idiom appears in `listview.lua`, `compile.lua` and the
search/grep panel, and **no Lua caller anywhere sets `read_only`**,
because no Lua `set_read_only` exists to call. It is owned by the
**`generated-buffer-immutability`** lane. Stage 2 does not implement
it, does not gate on it, and carries no acceptance for it; §3.1
records what that lane inherits from this document's re-scout, and why
the two lanes can land in either order without conflicting. (§3.1,
§11)
- **Q#DR26** *(new in rev 6, R3)* The drain outcome is **one ordered
sequence of settled resource mutations**, not a field per mutation
kind: `TickOutcome { settled, resources: Vec<ResourceOp> }` with
`ResourceOp::{Rename { from, to }, Remove { path }}`, and `PendingJob`
carrying a single `Option<ResourceOp>`. Ordered because a directory
rename and a delete beneath it can settle in the same tick and
reconciling them out of order targets the wrong path; one enum rather
than two `Option`s because two would admit a both-`Some` state that
cannot occur — `ResolvedTarget`'s own doc makes that argument at
`src/editor_core.rs:100-102`. Rev 5's `renames`-only shape could not
express deletion at all, though §6 required it to. (§5, §6)
- **Q#DR27** *(new in rev 6, R4)* "Kill a buffer" means **both** removal
phases, and `reconcile_delete` composes them for every id it kills:
`EditorCore::kill_buffer` (`src/editor_core.rs:4590` — last-buffer and
unknown-id refusals, side-window close, window redirect to a fallback,
registry removal) **then** `after_buffer_removed`
(`src/lua_bindings/mod.rs:1602` — keymaps, buffer-local config, folds,
`on_removed` callbacks). The composition already exists in exactly one
place, `pmacs.buffer.kill` (`mod.rs:5476-5491`), whose doc says it is
late-bound precisely because it needs the core to redirect windows;
`apply_resource_op` uses the **other**, incomplete path
(`remove_buffer_and_fire`, `mod.rs:1592`) and therefore leaves a
displaying window pointing at a removed id — a third defect on that
arm beside the missing dirty check and the first-match lookup. Phase 2
needs `&Lua`, so `reconcile_delete` returns the killed ids and its
caller runs phase 2; `EditorCore` gains no Lua handle. Both refusals
(last buffer, mid-edit `ConcurrentEdit`) are reported and neither
aborts the rest of the batch. (§6, §13 items 5153)
## 16. Branch and PR plan
@ -2278,8 +2595,10 @@ the document is longer, the re-scout is done, and **the census is six**
not a constant* — and it applies to the ledger's copy of this
framing's numbers exactly as it applied to the framing's copy of the
tree's. Whoever refreshes the lane should take the six-owner table from
§5 rather than re-deriving it, and should note that Q#DR25 added scope
the lane does not yet mention.
§5 rather than re-deriving it. Rev 6 removes the other half of what rev
5 said here: Q#DR25 **left** this lane rather than adding scope to it
(R1), and the ledger already records that at `docs/active-work.md:507`
— which is how round 5 caught the framing contradicting it.
### Ownership warning: 2a must not run concurrently with Journey Stage 1b
@ -2300,5 +2619,15 @@ change (surface the spawn failure with guidance) and 2a's is a
sequenced in either order provided only one is open at a time. Taken
late it is three merge rounds, which this arc has already paid twice.
Stage 2b and 2c carry no such overlap: 2b is `dired.lua` plus one
killring binding, and 2c is three additive `pmacs.fs` primitives.
Stage 2b and 2c carry no such overlap, but **2b's file list is wider
than rev 5 said** (R6). It is `dired.lua`'s mark, operation and
subscriber layer; the new `builtin/runtime/minibuffer.lua`; the
`pmacs.killring.push` binding; **and `src/editor.rs`**, because
`minibuffer.lua` has to join the explicit `include_str!` load sequence
there (`src/editor.rs:395-660`, ~30 entries) and must be ordered
**before** `dired.lua`. That is a one-line addition to a file no other
lane in flight is editing, so it changes no ownership conclusion — but
"2b is `dired.lua` plus one killring binding" was simply false, and a
reader sizing the ownership warning off it would have sized it wrong.
2c is three additive `pmacs.fs` primitives plus their two dired
commands.