pmacs/docs/dired-framing.md

77 KiB
Raw Permalink Blame History

Dired — framing

Revision 7 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162; Stage 1 MERGED as #165 (main @ c8ec8f3, one review round). Stage 2 (marks and operations) and Stage 3 (wdired) each still need their own framing before implementation; the frozen fixture shrinks after Stage 3. Rev 1 passed a ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixed round 2's six and was approved; rev 4 recorded what Stage 0's implementation falsified in the approved text (§0); rev 5 adds the coherence impact statement now required of every framing (CLAUDE.md, COHERENCE.md §20) — see §0.5; rev 6 records what Stage 1's implementation falsified (§0, S1-1…S1-9); rev 7 adds what its first review round found (§0, S1-10…S1-12). Deliberately unnumbered: the roadmap's Arc 8 is GPU structural parity but docs/lean4-mode-framing.md also claims Arc 8, so the arc space is already forked in uncommitted work. (Rev 2 also cited docs/dap-debugging-framing.md as part of that fork — wrong: its Arc 7 matches the roadmap's Arc 7 = Debugging. R2-5.) Numbering this one would mint a third claim; it is ranked when the roadmap is next reconciled. Not on docs/roadmap-2026-07.md and not in docs/side-quest-backlog.md — this framing proposes the work as well as its design.

0. Revision history

Round 1 (rev 1 → rev 2)

  • F1 (load-bearing). §3's rationale for freezing the M8 fixture was false. Rev 1 claimed the 47 tests pin the package system — install_local, on_unload unregistration, DuplicateName, per-package require scoping. Verified: install_local appears only in the shared editor_with_dired() harness and in doc comments, never in an assertion; on_unload, DuplicateName, and require-scoping are asserted nowhere in either file; dired_source_size_under_audit_ceiling is a lines < 1500 lint. Exactly one of 47 (dired_package_reload_is_safe_after_init_complete, m8_2_acceptance.rs:1190) exercises package mechanics; the other 45 assert dired/wdired behavior. §3 rewritten on honest grounds; the "shrink the fixture" follow-up repositioned from if drift appears to scheduled after Stage 3, because it is now known to be cheap.
  • F2 (load-bearing). RET must not use bare find_or_open. window_panel.rs:373-376 documents why: find_or_open "switches the ACTIVE window in both branches before firing hooks, so a visit to a previously unopened file would replace a focused panel." A RET in a panel-displayed dired would swallow the panel. Visits now route through pmacs.window.display_file (new Q#DR10), which also dedups by normalized path. §2 gains the window-primitive ground truth it was missing entirely, and the dired command gains the display opt that acceptance 11 was already testing without.
  • F3 (load-bearing). Stage 0's completion mechanism does not work as described. A function source is re-called per keystroke but invoked as f.call(())zero arguments (src/minibuffer.rs:591) — and the callback runs synchronously from Rust dispatch, outside any pmacs.async coroutine, where Handle:await() raises (async.lua:76-79). There is no synchronous directory listing in Lua, so a function source cannot descend into directories. Stage 0 rewritten around the existing Rust CompletionSource::Files (minibuffer.rs:589) plus free-text accept; hierarchical completion is a named deferral (new Q#DR11). Rev 1's "a file you have never opened is unreachable" is corrected to undiscoverable and uncompleted — recentf's prompt already passes free text to find_or_open.
  • F4 (design gap). Q#DR5(a) as specced did not cover dired's own hard case. apply_resource_op's rebind uses find_by_path (buffer_registry.rs:168-174) — exact Path equality, first match only — so a directory rename strands every buffer beneath it, and R on a directory line is an ordinary dired operation. Worse, that arm looks up with the raw path (mod.rs:3248) while stored paths are normalized on write (EditorCore::set_buffer_path, editor_core.rs:819) and the normalizing wrapper find_buffer_for_path (:864-867) exists and is bypassed — so a non-normalized old path silently fails to match today. Q#DR5 widened to include rebind semantics, with new evidence: pmacs.fs.rename has zero production callers, so changing the primitive's contract breaks nobody.
  • F5 (spec fix). Non-UTF-8 symlink targets are fatal too (fs.rs:227), and differ in kind from names: the entry's own name is fine and nothing needs to pass a target back through rename. Tolerant mode now carries readlink failures and target-encoding failures in the per-entry channel; only names stay fatal.
  • F6 / F7 (Q#DR2 gaps). Name-keyed dedup needs a canonical path form (/tmp, /tmp/, /tmp/../tmp would mint three buffers), and found-by-name must verify dired ownership before painting into a buffer through bypass_intercept. Both folded into Q#DR2.
  • Minors. Arc number dropped; C-x C-r attributed to recentf.lua:85 rather than the default keymap; the tolerant opt must validate unknown keys (supersede_key, fs.lua:73-83, silently ignores them, so a typo'd tolerant would degrade to fatal mode unnoticed); acceptance 13 carries the fixture's macOS ignore gate (m8_2_acceptance.rs:211-213); and §8 footnotes one per-entry failure that is already tolerated — a failing metadata.modified() yields mtime 0 rather than an error (fs.rs:463-476).

Round 2 (rev 2 → rev 3)

  • R2-1. Q#DR5's "at reply-settle time" named no seam, and the obvious one is wrong. The fs ops are fire-and-forget-capable — nothing obliges a caller to await or attach on_complete — so a rebind implemented where results are consumed (_take_result, mod.rs:6758) misses any rename whose handle is never taken: the rename lands on disk and the buffer is never rebound. The trap survives one layer down, and an acceptance that awaits would pass while the fire-and-forget path stayed broken (the pin-through-the-real-path class again). §7 now names the main-thread completion drain AsyncRuntime::tick (async_runtime.rs:991) as the seam, unconditional on success — plus a fact that makes the implementation non-obvious: rename settles as an undifferentiated ReplyKind::FsUnit, the same reply chmod and remove produce (:1022-1025 maps Sleep | FsUnit alike to JobResult::Unit; there is no Rename variant). The drain therefore cannot key on the reply — it must key on the pending job's own JobKind::FsRename, and the job must retain from/to so the paths exist at settle. Stage 2's acceptance includes a no-await rename.
  • R2-2. The errors row shape cannot always carry a name. A per-entry readdir iterator error (fs.rs:215-218) has no filename — the entry never materialized, and the error is wrapped with the parent path. §8 makes name optional for that arm; the footer counts it without naming it.
  • R2-3. §2's pmacs.window inventory listed five exports; there are eight — also display_target (:425), panel (:440), and set_params (:544). The omission was the relevant one: display_target is "the non-side window a visit from a panel should address", i.e. the mechanism behind display_file's panel-safety. §9's loosest sentence — directory descent in a panel-displayed dired, left as "(or display, when dired was itself panel-displayed)" — is now specified, with the dedication question answered.
  • R2-4. The Lua mirror of normalize_buffer_path is a second implementation of a canonical form — the tab-width-constants class in miniature. If the mirror and the Rust normalizer disagree on an edge (//tmp, ~ with HOME unset, root's trailing slash), dired's name-dedup and display_file's find_buffer_for_path dedup diverge silently: two buffers, no error. §4 now carries a parity obligation, and records that the "binding whose only caller is dired" argument undercounts — Q#DR5's fix touches the same normalizer, so a Lua-exposed canonicalize has at least two consumers by Stage 2.
  • R2-5. Header nit: only Lean 4 forks the arc numbering; DAP's Arc 7 matches the roadmap. Conclusion unchanged, evidence corrected.
  • R2-6. Stage 0 was "recommended first" with no acceptance and no ruling on nonexistent paths. display_file routes through resolve_target_buffer (editor_core.rs:885-898), which on ErrorKind::NotFound creates the buffer, binds the path, and sets status "[new file]" — Emacs parity, now stated rather than inherited. §14 gains four Stage 0 acceptance items.

Stage 0 implementation notes (rev 3 → rev 4)

Implementing Stage 0 falsified one thing the approved text asserted, and the correction belongs here rather than only in the code.

  • S0-1. Flat completion and free-text accept do not compose the way Q#DR11 described. Rev 3 said Stage 0 is "the flat Rust Files source rooted at the current buffer's directory, plus free-text accept", as though the two were independent and always both available. They are not: recompute_candidates sets selected = Some(0) whenever the candidate list is non-empty (minibuffer.rs:372-377), and resolve_accepted_value (:564-574) returns the selected candidate in preference to the typed contents. So typed text reaches on_accept only when the input filters every candidate away — which, since candidates are bare basenames and the filter is a case-insensitive subsequence match, means when the input contains a /. Consequences, all now pinned by tests/find_file_acceptance.rs:

    • the deeper-path case works (sub/inner.txt matches no basename, so it arrives verbatim) — which is what acceptance 0b actually tests;
    • the new-file case works only for names containing a separator, so acceptance 0c uses one;
    • and there is a genuine hole: typing a new bare name that is a subsequence of an existing entry opens the existing file instead of creating the new one. find_file_selected_candidate_shadows_typed_text pins that as a decision rather than an accident.

    Closing the hole needs a Rust change to accept semantics — prefer typed text over the selection when the two differ and the user has not explicitly moved the selection — which would change M-x and switch-buffer too, and so is deliberately not made in Stage 0. It joins the hierarchical-completion deferral in §13.

  • S0-4. Accepting on empty input opens the first-sorted candidate. A consequence of S0-1 with an empty needle: fuzzy_score returns Some(0) for every entry (minibuffer.rs:637-640) and filter_and_sort breaks the resulting tie lexicographically (:678), so an immediate RET opens whatever sorts first — dotfiles lead, and a directory can lead, in which case the open fails and reports (S0-6). M-x and switch-buffer share the mechanism, so this is inherited rather than introduced; it is documented at the command and listed in §13 beside the accept-semantics fix that would close it.

  • S0-5. Minibuffer history stores the pre-join value. accept pushes the resolved value into the history bucket before on_accept joins it onto the root, so a C-p recall of a root-relative entry under a different root resolves somewhere else. Rust-side, so Stage 0 cannot fix it; §13.

  • S0-6. The failure arm is a real path and is pinned. Accepting a directory candidate reaches display_file, whose load fails (File::open on a directory succeeds; the read returns EISDIR), so the command's pcall turns it into a status message instead of letting the error escape mid-dispatch. find_file_accepting_a_directory_reports_instead_of_raising pins it through the real accept path and fails when the pcall is removed.

  • S0-2. The prompt field must start empty. Emacs prefills find-file's field with the directory. Here any prefill contains a /, which by S0-1 filters every candidate away and silently disables completion — so the root is named in the prompt string instead, and the empty field is pinned by acceptance 0d.

  • S0-3. A leading ~ must be expanded before the path reaches the core. get_or_load_buffer (editor_core.rs:842-856) computes a normalized path but calls load_file with the raw one (:847), so a ~/… path deduplicates against an already-open buffer (dedup goes through the normalizing find_buffer_for_path) yet fails to load a file that is not open yet. Stage 0 expands the tilde in Lua, which makes both halves agree without changing core load semantics for the CLI, LSP, and bootstrap callers. Using the normalized path for the load is the better fix and is now a named deferral (§13) — it is the same normalize-before-lookup family as Q#DR5's apply_resource_op correction.

Stage 1 implementation notes (rev 5 → rev 6)

Implementing Stage 1 (PR #165) falsified four things the approved text asserted and settled five it left open. Recorded here rather than only in the code, per the rev-4 precedent.

  • S1-1. The normalizer is EXPOSED, not mirrored — so B2 is partly false, in the direction Q#DR2 preferred. Q#DR2 made the mirror conditional (Stage 1 may still mirror if exposure turns out to drag in EditorCore borrow plumbing it does not otherwise need). normalize_buffer_path is a free function (editor_core.rs), so exposure drags in nothing: it is now pub and reachable as pmacs.path.canonicalize. Consequences, all deliberate: B2 ("tolerant read_dir is the only Rust change Stage 1 needs") is false by one small binding; acceptance 3b degenerates to the round-trip form the framing described; and the Stage 2 mirror-removal follow-up is not owed — there is no second canonical form to remove. The parity acceptance is still carried, now as "the Lua binding and the Rust function agree over one shared edge list", which is exactly the claim a future re-mirroring would break.
  • S1-2. R2-3's dedication claim is falsified by the substrate. It read "a dedicated dired panel stays dedicated across descent and the new dired buffer inherits it". display_buffer never replaces the buffer in a slot dedicated to another one: it discards every side-specific parameter and falls back to the document window (Q#BP3 2.iii), and the exact-window arm errors outright. Dired therefore does not try to unpin the user's panel — which is also what Emacs's display-buffer does with a dedicated window. Acceptance 3c is split: a non-dedicated panel keeps the descent, and a dedicated one keeps its buffer and its pin while the new directory appears in the document window.
  • S1-3. Acceptance 3c cannot pin the descent ROUTING, and the test now says so. Dired holds the focus in its own panel, so a raw switch_buffer lands in that same window and every 3c assertion holds either way — the mutation is vacuous against it. Dedication is the only thing that distinguishes display { side = … } from the raw switch, so the dedicated-panel test is the discriminating pin. Found by running the bite rather than by reading the test; the vacuity is documented at the assertion instead of being left to be believed.
  • S1-4. Dired is the first builtin to bind a mode-scoped key, and one pre-existing lib test assumed none existed. describe_key_identifies_every_default_binding iterated every binding in the stack and asserted pmacs.describe.key resolves it context-free, which held only while the modes table was empty. It now sets the effective context per binding — and explicitly clears the mode for a global one, because a mode left over from a previous iteration legitimately shadows a global chord of the same name (dired's RET shadows edit.newline-and-indent, which is the point of the mode).
  • S1-5. C-x d deliberately takes NO completion source. It is the direct consequence of S0-1/S0-4: with a files source, RET on an empty field opens whatever sorts first (the minibuffer selects candidate 0 whenever the list is non-empty, and a selected candidate shadows typed text), and RET-on-the-directory-you-are-in is exactly the gesture C-x d exists for. The field is prefilled with the current directory instead — Emacs's own shape here — and free text always reaches on_accept because CompletionSource::None bypasses candidate resolution entirely. Directory-name completion is what dired itself replaces.
  • S1-6. Ownership is the handle table ALONE, narrower than Q#DR2's "present in dired's handle table, or major_mode(buf) == "dired"". A foreign buffer that carries the mode is the case the check exists to refuse, and a builtin's handle table cannot be lost the way a reloadable package's can. Acceptance 4 sets the mode on the foreign buffer to pin the stronger reading.
  • S1-7. The mark column ships in Stage 1, rendered blank. Q#DR4 is a Stage 2 decision, but reserving the two columns now means Stage 2 does not move every offset and Stage 3's column-classifying intercept can be written against constants that did not shift under it. The constants are computed from the widths (the fixture hardcoded NAME_START = 39 and paid for it in every wdired test) and exported as pmacs.dired._layout so acceptance cannot drift from them.
  • S1-8. A symlinked directory needs a probe, because kinds are lstat-based. Both read_dir and stat report a link as "symlink", so nothing in the entry says whether it points at a directory. RET on a symlink therefore tries to list the target (one extra syscall, on symlink lines only) and descends if that succeeds, else visits it as a file. Q#DR10 specified only the dir/file arms; this is the third.
  • S1-9. Interactive origin does not survive the await. Every listing is worker-dispatched, so the work after the first :await() resumes inside tick_async, where InteractiveCommandOrigin is empty and pmacs.window.* falls back to the ambient active frontend. Single frontend: correct. Multi-frontend: a dired opened from peer B while A is ambient would display for A. Not fixable from Lua (the display surface takes no frontend argument) and named here rather than discovered later.

Stage 1 review round 1 (rev 6 → rev 7)

Three findings changed behavior; the rest were naming and comments. Each fix is bite-verified against the test that names it.

  • S1-10. An ambient re-seat is not safe after an await. dired.revert painted its own buffer by name (safe) and then re-seated through pmacs.editor.move_to_line, which moves whatever window is active — so a user who switched buffers while the re-read was in flight had an unrelated buffer's cursor moved to a line index meaningful only in the dired listing. This is the buffer-level instance of the hazard S1-9 named at the frontend level, and it generalizes: in this codebase, painting takes a buffer and seating takes the world. Any post-await cursor operation needs an active-buffer guard; open_directory is exempt only because it displays the buffer first.
  • S1-11. The rendered columns are a contract, so precision yields to width. %10d overflowed at 10 GB (VM images, core dumps), widening the size field and shifting mtime and name right on that line alone. Cosmetically harmless today, but _layout is exported and Stage 3's column-classifying intercept is planned against it, so a contract-violating line now is a Stage 3 trap. fmt_size took fmt_mtime's shape: exact bytes while they fit, else a fixed-width magnitude. Not the deferred human-readable column (§13) — the exact count still renders right up to the point where it cannot.
  • S1-12. open_directory's "changed nothing on failure" invariant is reusable as a PROBE. S1-8's symlink descent originally listed the target to learn its kind and then opened it — two full listings of the same directory. Because a failed open touches no editor state (acceptance 15), the open itself is the probe: try the descent, fall back to display_file. One read. The comment that claimed "one syscall" for a full read_dir is corrected rather than left as a cost claim nobody would re-check.

Also, on the tolerant channel (Q#DR6): a readdir iterator may keep yielding errors without terminating, and cancellation is not a backstop for a dired listing — it carries no supersede key, so nothing cancels it. A consecutive-error cap now fails the listing the way an unopenable directory fails, rather than accumulating error rows on a worker thread. It is deliberately untested: faking a failing iterator would need the walk generic over it, a refactor with no other consumer.

0.5. Coherence impact (COHERENCE.md §20)

Required of every framing since #163. This arc was scouted and approved before that rule existed; the statement is added here rather than backfilled silently.

Section served: §20 Priority 1 — protect the golden product journey, which already names this work: "a find-file surface (in flight, PR #162)" and "directory-argument handling". Secondary: §5 (unify discoverability) and §14 (coherent workbench primitives).

Journey steps touched (§2).

  • Step 7, "find a symbol or file" — the file half, which had no surface at all. Stage 0 (C-x C-f, merged as #162) covers opening a known path; Stages 13 cover browsing, which is the half a user reaches for when they do not already know the path.
  • Step 3, "open a real project" — partially, and the boundary matters. §2's ground truth grades the journey broken at step 3 because pmacs . exits 1: load_file (src/file_io.rs:81-87) does File::open (which succeeds on a directory) then read_to_end → EISDIR, which is not NotFound, so resolve_target_buffer's create-a-[new file] arm never fires and the error escapes. That is the same mechanism Stage 0 pinned in find_file_accepting_a_directory_reports_instead_of_raising — where the pcall turns it into a status message instead. Dired Stage 1 is what makes a directory open into something rather than merely fail politely. Boundary with the adjacent arc: §20's arc-cut list puts CLI directory-argument handling in "Journey Stage 1", noted as riding alongside this arc. The two meet at resolve_target_buffer. This framing does not claim the CLI path; it supplies the buffer a directory should resolve to, and Journey Stage 1 should route pmacs . into it rather than inventing a second directory surface.
  • Step 4, "understand the visible interface" — marginally, via the dired major mode showing in the statusline (Q#DR8).
  • Steps 12, 56, 812: untouched.

Interaction islands added (§6): none — deliberately. §6 grades this area "weak, and growing by one island per modal feature", with every modal surface funnelling through EditorInstance::dispatch_key's precedence machine. Dired adds no Rust-level interception: its keys are an ordinary mode-scoped keymap through the existing pmacs.keymap registry (Q#DR8), so they are introspectable by describe.key and rebindable like any other binding. Stage 3's wdired is a major-mode swap, not a modal layer — which is the reason Q#DR3 chose a mode swap over an edit-mode flag. Two existing islands are consumed (the minibuffer prompt for C-x d, and Stage 0's), neither added by this arc. This arc therefore moves §6's count sideways, not up.

Config registry adoption (§11): yes. dired.kill-when-opening is defined through pmacs.config with a type, default, and mutability = "live" (Q#DR2), not a bare Lua global — matching the #127 adopters. Sort mode is deliberately not a setting in Stage 1: it is per-buffer session state, and promoting it would need the buffer-local scope plus a persistence story the registry does not have yet (its own named deferral).

Background-work attribution (§9): inherited debt, not fixed here. Every listing runs as a pmacs.fs.read_dir worker job, and those jobs carry no owner or purpose — §9's gap. A dired refresh will therefore show up in the activity planes exactly as anonymously as every other fs job does today. Stage 1 does not fix that and does not make it worse; when §20's "worker identity" arc lands, dired's jobs are ordinary consumers of it. Naming it here so the debt is visible rather than silently compounded (§1.3).

Net. One journey step goes from no surface to a surface; one more moves from fails toward resolves; no island added; one setting enters the registry; one attribution gap inherited and named.

1. Problem and what ships

Two separate facts collide here, and the second is why this is worth more than "a file browser would be nice".

Fact one: a complete dired already exists, and ships to nobody. tests/fixtures/pmacs-dired/init.lua is 1,384 lines of Lua implementing the read-only directory view (T M8.2) and the wdired editable rename/chmod layer (T M8.3), pinned by 47 acceptance tests (15 in tests/m8_2_acceptance.rs, 32 in tests/m8_3_acceptance.rs). It was built as one of M8's three "universality proof" packages — the evidence that a buffer can be a projection of external state. It lives under tests/fixtures/, and rg pmacs-dired outside tests/ returns zero hits. Nothing installs it; no user can reach it.

Fact two: pmacs has no discoverable way to open a file by path. There is no find-file command and no C-x C-f binding. The complete list of builtin command names contains nothing matching file or open; pmacs.buffer.find_or_open (src/lua_bindings/mod.rs:3104) is a Lua API with no interactive caller of its own. A file enters a session via the CLI (pmacs FILE, pmacs --gpu FILE), an LSP jump, a project-search visit, or C-x C-r recent-files — whose prompt does pass free text through to find_or_open (recentf.lua:74-80), so an arbitrary path is technically reachable, but only by typing it blind into a prompt labelled "Recent file:" with no completion and no discoverability. editor.switch-buffer (builtin/commands/default.lua:594) completes over already-open buffers and reports no buffer: <name> for anything else.

So dired is not a convenience rider on an existing file surface. Dired is the file surface. That reframes both its value and its risk: it is the first thing a new user needs, and the last place we can afford a listing that refuses to render.

What ships, staged (§10):

  • Stage 1 — the dired view. A builtin builtin/runtime/dired.lua: read-only listing, navigation, RET to visit (files open through display_file, directories descend), sort modes, revert, quit, C-x d / C-x C-j, a dired major mode with mode-scoped keys, cursor preservation across refresh — plus the one Rust change Stage 1 needs, a per-entry-tolerant read_dir (Q#DR6).
  • Stage 2 — marks and operations. m/u/U/t, deletion flags d/x, immediate D, R rename, C copy, + mkdir — and the three filesystem primitives that do not exist yet, plus the rename/rebind fix.
  • Stage 3 — wdired. The editable layer, carrying over the fixture's hard-won commit logic.

find-file itself (C-x C-f) is separable and is Stage 0 (§10).

2. Ground truth (scouted 2026-07-25, main @ e745068; verified across review rounds 1 and 2; re-verified against main @ 0827dd1)

Base note. main moved from e745068 to 0827dd1 (Lean 4 Stage 1, #160) between the scout and approval. The diff touches exactly one file this framing cites — builtin/runtime/syntax.lua, which gained a lean = "lean4" modeline alias — and nothing else in the ground truth below. The only consequence is a line drift: set_major_mode is now syntax.lua:497, not :492. Every other citation is unchanged.

The existing fixture

  • tests/fixtures/pmacs-dired/init.lua (1,384 lines) defines eight commands: open-line, parent, sort-name, sort-mtime, sort-size, wdired-edit, wdired-abandon, wdired-commit. It binds RET and Backspace buffer-locally at open (:381-388), paints by wholesale buf:replace behind a painting passthrough flag (:294-302), and keys per-buffer handles by linear scan over BufferIdLua.__eq with a liveness compaction (:53-67).
  • Its wdired layer is the valuable part and is not naive: a column-classifying intercept_edit (:640-712), fixed-width perms with positional validation (:520-542), \\/\n/\r/\t/\xNN filename escaping with an exact inverse so a no-op commit cannot fire a spurious rename (:140-211), field-by-field external-change detection including mtime_nsec (:854-902), duplicate-final-name rejection before any syscall, and a two-phase rename through unique temp names so swaps and chains commit safely (:1176-1216).
  • What the 47 tests actually assert (F1). 45 assert dired/wdired behavior: rendered listing shape, sort order, escaping round-trips, intercept column rejection, on-disk chmod/rename effects, the two-phase swap, external-change detection, partial-application reporting. One (m8_2:1190) exercises package mechanics — reload safety after set_init_complete. One (m8_2:1245) is a source-line-count lint. install_local appears only in the shared editor_with_dired() harness as a setup precondition and in doc comments; on_unload, DuplicateName, and per-package require scoping are asserted nowhere.
  • Two of its own stated limitations are now false.
    • open-line on a non-directory errors with "requires the buffer-from-file API (not yet exposed)" (:948-961). pmacs.buffer.from_file (mod.rs:3054) and find_or_open (:3104) both exist and ship.
    • The test seam claims "the v0.1 buffer surface doesn't expose move_to_byte yet, so tests can't reliably position the cursor" (:1355). pmacs.editor.goto_byte (mod.rs:12765) and move_to_line (:12526) landed with editops (#111).
  • A real defect in its model: navigation mutates handle.path and repaints, but the buffer was named *dired:<path>* at creation and there is no pmacs.buffer.set_name — the pmacs.buffer table exports exactly create, from_bytes, from_file, find_or_open, list, kill, remove, on_removed, major_mode, set_major_mode, set_round_trip_input, mark_create, add_intercept, remove_intercept, apply_resource_op, and the style-overlay family. (Buffer::set_name exists Rust-side, unexposed.) So after one RET the buffer name names a directory it is no longer showing. Q#DR2 answers this.

The filesystem surface

  • pmacs.fs is exactly five worker-dispatched ops — read_dir, stat, rename, chmod, remove (the complete _dispatch_fs_* set) — plus a Lua-side polling fs.watch (builtin/runtime/fs.lua:226). No mkdir, no copy, no symlink-create, no recursive remove.
  • read_dir is all-or-nothing, and this is the load-bearing gap. read_dir_blocking (src/fs.rs:201) returns Result<Vec<FsDirEntry>, FsError>. Five per-entry conditions fail the entire listing: a per-entry readdir error, a failed symlink_metadata, a failed read_link (:228-234), a non-UTF-8 symlink target (:227), and a non-UTF-8 name (:238). The module doc acknowledges the shape and says "dired-class will likely want a per-entry-tolerant wrapper but that's the package's job, not the primitive's" (:196-200) — that wrapper cannot be written in Lua. The primitive hands Lua one structured error and no partial vec; there is nothing to be tolerant with. Three concrete failure modes, all ordinary:
    1. a directory readable but not searchable (r without x) — readdir succeeds, every child lstat fails;
    2. a file unlinked between readdir and lstat — ENOENT, i.e. a plain refresh of a busy directory (/tmp, a build tree) can just fail;
    3. any single non-UTF-8 filename or symlink target in the directory. One per-entry failure is already tolerated: a failing metadata.modified() yields mtime 0 rather than an error (:463-476).
  • read_dir already takes (path, opts) and the opts parser (supersede_key, fs.lua:73-83) reads only opts.supersede and silently ignores unknown keys — signature-natural for Q#DR6's opt, but a typo'd tolerant would degrade to fatal mode unnoticed.
  • chmod follows symlinks (src/fs.rs:370; fs.lua:104) while read_dir/stat use lstat. The fixture rejects symlink perms edits at intercept time for exactly this reason (init.lua:629-638) — that decision carries over unchanged.
  • pmacs.fs.rename has zero production callers — only its own definition (fs.lua:126), m8_1/m8_3 acceptance, and the fixture.
  • pmacs.fs.rename does not rebind an open buffer's path. pmacs.buffer.apply_resource_op (mod.rs:3206) — the LSP workspace-edit applier — does, but by exact first match: its "rename" arm calls reg.borrow().find_by_path(&from) (:3248), which is exact Path equality over insertion order (buffer_registry.rs:168-174). It also uses the raw path while stored paths are normalized on write (EditorCore::set_buffer_path, editor_core.rs:819) and the normalizing lookup find_buffer_for_path (:864-867) exists and is bypassed. So the model rev 1 proposed copying is itself subtly wrong, and has no prefix rebind anywhere. apply_resource_op is also synchronous and blocking on the main thread, unlike every pmacs.fs op. Q#DR5.

Windows, panels, and how anything gets displayed

  • pmacs.window exports eight functions: display (window_panel.rs:356), display_file (:379), display_target (:425), panel (:440), quit (:453), params (:499), set_params (:544), and resize (:591) — alongside the pre-arc switch_buffer. display_target is "the non-side window a visit from a panel should address", i.e. the mechanism that makes display_file panel-safe; it is what Q#DR10 rests on, and it is the reason a file visit and a directory descent take different routes (§9).
  • find_or_open is panel-hostile, by documented design. window_panel.rs:373-376: find_or_open "switches the ACTIVE window in both branches before firing hooks, so a visit to a previously unopened file would replace a focused panel before any display policy could help." display_file is the Q#BP11b answer — a side-effect-free dedup via the normalizing find_buffer_for_path before any I/O, then destination resolution before the read, so a dedicated origin cannot force load-before-failure. LSP visits and compile already route through it.
  • pmacs.listview (builtin/runtime/listview.lua) implements the disciplines a read-only panel needs: a read-only add_intercept (:101-104), set_round_trip_input (:106), a buffer-local keymap, a line→item map, q-restores-previous, and the display = "current" | "panel" opt-in (:132-142). But its keymap is a fixed set — RET/SPC/n/p/g/q (:76-88) — with no extension point, and its read-only intercept is installed once at panel creation and never removed (:101), which wdired requires. Three panels already depend on this module (references, outline, project-search).
  • set_round_trip_input (mod.rs:3085) is what makes single-key bindings work on a semantic/GPU frontend: while a marked buffer is active dispatch_idle reports false, so optimistic-apply stays off and d reaches the binding instead of landing as a CRDT insert. The same gate (dispatch_idle_for, editor.rs:818) also disables optimistic apply for any focused side window (!window.is_side(), Q#BP14a), so a panel-displayed dired is covered twice.
  • There is no real read_only buffer flag — a standing backlog item (docs/side-quest-backlog.md, cross-cutting substrate). The intercept idiom is what every generated buffer uses today.

Minibuffer completion

  • A source function is re-called on every keystroke (recompute_candidates, minibuffer.rs:361) but invoked as f.call(())zero arguments (:591). pmacs.minibuffer.contents() exists, but the callback runs synchronously from Rust dispatch, outside any pmacs.async coroutine, and Handle:await() explicitly raises there (async.lua:76-79). There is no synchronous directory listing in Lua, so a function source cannot descend into directories.
  • CompletionSource::Files { root } exists Rust-side (minibuffer.rs:589list_directory(root)), reached from Lua as source = "files" with source_root (mod.rs:13286). It is flat, single-directory, capped at 1024 candidates, and currently used by nothing outside a unit test.
  • Free text accepts: resolve_accepted_value (minibuffer.rs:564) returns the raw typed string when no candidate is selected.

Buffers, modes, keys

  • Mode-scoped keymaps exist since #129: pmacs.keymap.bind { scope = "mode", mode = "<name>", … } (mod.rs:13432), resolving buffer-local → mode → global. syntax.lua:497 is the only set_major_mode caller in builtin/, firing on buffer.after-load; a buffer.created dired buffer has no path and fires no after-load, so nothing contends.
  • find_or_open on a directory reaches file_io::load_file → EISDIR. Dired must dispatch on entry.kind itself.
  • Keybinding space. C-x d, C-x C-j, C-x C-f, and C-x C-q are unbound repo-wide. C-x C-r is bound to recent-files in recentf.lua:85 (not the default keymap). C-c <letter> is fully taken by LSP (lsp.lua:2248-2256); C-c @ is the folding prefix (fold.lua:48-52); C-c C-k is bound buffer-locally by compile (compile.lua:231) and async, not globally.

3. Where dired lives (Q#DR1)

A builtin runtime module, builtin/runtime/dired.lua, written fresh. The M8 fixture stays exactly where it is, frozen.

Builtin rather than a package, because a surface that is the primary way to open a file cannot be gated on the user installing something, and because builtin modules get the load-order and config-registry guarantees a package does not (the pair.lua-before-lsp.lua precedent).

Why the fixture is not promoted. Rev 1 argued the 47 tests pin the package system and would be voided by re-pointing. That was false (F1) — 45 of them assert dired behavior, and behavior transfers. The honest argument is narrower and rests on three things:

  1. The harness route is itself the proof. 46 of 47 tests reach dired through install_local + require, and that routing — a third-party package, in its own environment, driving buffers, intercepts, marks, commands, and keymaps — is the M8 universality claim. A builtin loaded by the runtime demonstrates nothing about packages. Re-pointing the suites keeps every behavioral assertion and silently deletes the claim they were written to support.
  2. The builtin diverges structurally, so the tests cannot transfer verbatim anyway: the mark column (Q#DR4) shifts every column offset the wdired tests hardcode through _test.NAME_START; mode-scoped keys (Q#DR8) replace the buffer-local RET/Backspace binds dired_ret_and_backspace_keybindings_navigate drives; buffer-per-directory (Q#DR2) replaces the in-place repaint that dired_parent_command_navigates_up_one_level asserts; and the whole M._test seam is package-shaped.
  3. The behavior gets re-pinned regardless, by the builtin's own acceptance (§14), which is where those 45 assertions are owed a home.

The cost is real: roughly 900 lines of rendering, escaping, and commit logic will exist in two places. The mitigation is that the fixture is frozen — a proof artifact, not a maintained feature, already fully pinned.

Named follow-up, scheduled rather than conditional (F1 corollary). Because the fixture's package-system value concentrates in exactly one test plus the harness routing, shrinking it to the minimum payload that still proves universality is cheap — far cheaper than rev 1 implied. It is scheduled after Stage 3, when the builtin owns every behavior the 45 tests currently cover, not left to "if drift shows up".

The fixture remains a reference for the parts that were hard — escaping with an exact inverse, two-phase rename, external-change detection, the symlink rules — and those carry over as decided design, not as re-litigated questions.

4. Buffer model and navigation (Q#DR2)

One buffer per directory, found-or-created by canonical name; navigation opens the target's buffer rather than mutating the current one.

This is Emacs's actual behavior (dired-find-file on a directory yields a dired buffer for that directory), and it answers the fixture's stale buffer-name defect without adding a pmacs.buffer.set_name binding: nothing is ever renamed, because a buffer's name always describes the directory it was created for. It also makes C-x d on an already-visited directory and C-x C-j dedup for free.

Canonicalization (F6). /tmp, /tmp/, and /tmp/../tmp are all absolute and would otherwise mint three buffers. The rule is lexical normalization before naming and before lookup: expand a leading ~, absolutize, collapse . and .., strip a trailing slash except at root. Symlinks are deliberately not resolved — Emacs parity, and resolving them would make .. from a symlinked directory jump somewhere the user did not navigate from. The core already has exactly this shape in normalize_buffer_path (editor_core.rs:4790); it is not Lua-exposed, so Stage 1 either mirrors it in Lua or exposes it.

A mirror is a second implementation of a canonical form, and needs a parity pin (R2-4). This is the tab-width-constants class in miniature: if the Lua mirror and the Rust normalizer disagree on an edge — //tmp (POSIX gives a leading double slash implementation-defined meaning), ~ with HOME unset, root's trailing slash, a .. that would escape root — then dired's name-dedup and display_file's find_buffer_for_path dedup diverge silently: two buffers for one directory, no error anywhere. So whichever route Stage 1 takes, it carries a parity acceptance that drives both implementations over one shared edge-case list and asserts identical output (acceptance 3b).

Rev 2's "avoids a binding whose only caller is dired" also undercounts: Q#DR5's rename fix touches the same normalizer, so a Lua-exposed pmacs.path.canonicalize (or equivalent) has at least two consumers by Stage 2. Exposing it and deleting the mirror is therefore the better end state; Stage 1 may still mirror if exposure turns out to drag in EditorCore borrow plumbing it does not otherwise need, but the parity acceptance is required either way, and the mirror is then a named Stage 2 removal, not a permanent duplicate.

Ownership check (F7). pmacs.buffer.create takes any caller-chosen name, so a foreign buffer named *dired:/tmp* would be "found" by name and then painted into through bypass_intercept. Found-by-name must confirm the buffer is dired-owned — present in dired's handle table, or pmacs.buffer.major_mode(buf) == "dired" — and otherwise create a fresh buffer under a disambiguated name rather than clobbering it.

Buffer name: *dired:<canonical absolute path>*.

The cost is buffer accumulation when walking a deep tree. Emacs users live with this; Emacs 28 added an opt-out, and we mirror it as a config key rather than a hardcoded policy:

  • dired.kill-when-opening (boolean, default false, live) — when true, descending or ascending kills the dired buffer being left.

5. Read-only discipline and the wdired seam (Q#DR3)

The dired buffer is read-only by the listview idiom — an add_intercept that rejects every non-bypass edit, plus set_round_trip_input(buf, true) so a GPU session's optimistic-apply cannot swallow single-key bindings — and dired's own paints use bypass_intercept = true.

Dired owns its buffer directly; it is not built on pmacs.listview. Listview would have to grow a keymap extension point and a removable read-only mode, and three shipped panels depend on it. Bending a module into a shape its existing callers do not need is exactly the change class that took CI red in #155: scoping pmacs.window.buffer()'s no-arg arm "for consistency" made a total function partial and silently dropped edits from six unpcall'd runtime callers. Dired reuses listview's disciplines, not its code. Factoring the shared disciplines into a common helper is a named follow-up, to be done once dired's real shape is known rather than predicted.

Wdired (Stage 3) is a mode swap, not a flag. C-x C-q removes the read-only intercept, installs the column-classifying one, and calls set_major_mode(buf, "wdired"); commit and abandon restore both. Because keys are mode-scoped (Q#DR8), the entire keymap changes with the mode — m means "mark" in dired and means "type an m" in wdired, with no per-key bookkeeping.

Wdired refuses to open on a partially-listed directory. If the listing carried any per-entry error (Q#DR6), rename and chmod are refused with that reason: a rename batch is planned against a snapshot, and you cannot safely plan against a directory you could not fully see.

6. Marks (Q#DR4)

Emacs's mark column is column 0, so every other column shifts right by two (" " or "* "). The builtin computes its offsets from the constants rather than inheriting the fixture's PERMS_START = 1 / NAME_START = 39.

Marks are keyed by basename, never by line index. A sort, a revert, or an external change reorders lines; a line-indexed mark set would silently retarget onto a different file — the same class of defect as keying kill-ring state by index rather than by stable id (the Arc 2 substrate rule). *buffer-list* keys its deletion marks by buffer id (builtin/commands/default.lua:507) for the same reason.

Two mark characters, following Emacs: * (general mark, consumed by operations) and D (deletion flag, consumed by x). A basename that disappears between marking and executing is dropped from the batch and reported, not silently skipped.

7. Operations and the missing primitives (Q#DR5)

Stage 2's operations need three filesystem primitives that do not exist, and one correctness fix.

New pmacs.fs ops (worker-dispatched, matching the existing five; mutating ops take no supersede, per fs.lua:101-124):

  • pmacs.fs.mkdir(path, opts)opts.parents for create_dir_all.
  • pmacs.fs.copy(from, to, opts) — regular files in v1; a directory source is refused rather than silently shallow-copied. Preserves mode bits; opts.overwrite defaults false and the op refuses an existing target otherwise.
  • pmacs.fs.remove_dir_all(path) — separate from remove rather than a flag on it, so a recursive delete can never be reached by a caller that meant the single-object op.

The rename rule — decided, with the semantics widened (F4). A rename must rebind open buffers, and it must do so at the primitive: pmacs.fs.rename has zero production callers, so changing its contract breaks nobody, and leaving the trap armed guarantees the next caller rediscovers it the expensive way. The rebind is:

  • prefix-aware, not exact-match. apply_resource_op's find_by_path (buffer_registry.rs:168-174) is exact Path equality, first match only — which strands every buffer beneath a renamed directory, and R on a directory line is an ordinary dired operation. The reconcile rebinds the renamed path itself and every buffer whose path has it as a path-component prefix.

  • normalize-before-lookup. Stored paths are normalized on write (editor_core.rs:819) and the normalizing wrapper find_buffer_for_path (:864-867) already exists; apply_resource_op bypasses it with a raw lookup (mod.rs:3248), which is a latent miss today. The new path goes through the wrapper. apply_resource_op's own raw lookup is fixed in the same change — it is the same bug, one call site away.

  • in the main-thread completion drain, AsyncRuntime::tick (async_runtime.rs:991), unconditionally on success — not where results are consumed. This is the load-bearing half of the decision (R2-1). The fs ops are fire-and-forget-capable: nothing obliges a caller to await or attach on_complete, so a rebind hung off _take_result (mod.rs:6758) would miss every rename whose handle is never taken — the rename lands on disk, the buffer is never rebound, and the trap survives one layer below where we thought we fixed it. An acceptance that awaits the rename would pass throughout, so Stage 2's acceptance includes a no-await rename and bites against the drain.

    Two facts make this non-obvious to implement. Rename settles as an undifferentiated ReplyKind::FsUnit — the same reply chmod and remove produce; there is no Rename variant, and the drain arm maps Sleep | FsUnit alike to JobResult::Unit (async_runtime.rs:1022-1025). So the drain cannot key on the reply; it must key on the pending job's own JobKind::FsRename. And the from/to paths live only in the dispatch call today, so the pending job must retain them for the drain to have anything to rebind with. Both are additive to async_runtime.rs; neither changes the wire or the worker contract.

v1 supports R on a directory — that is precisely what prefix-aware rebinding buys, and refusing it while C refuses directory sources for a different reason (no recursive copy primitive) would be an arbitrary asymmetry. Stage 2's acceptance pins the directory case explicitly.

Confirmation. Destructive operations (x, D, recursive delete, overwriting copy) prompt. There is no y_or_n helper — a named autosave-arc deferral — so Stage 2 adds one rather than repeating autosave.lua:219's two-element minibuffer.read at four call sites.

8. Tolerant listing — the Stage 1 Rust change (Q#DR6)

read_dir grows a per-entry error channel behind an opt. The Lua result under { tolerant = true } becomes:

{ entries = { <entry>, ... }, errors = { { name = "..." | nil, message = "..." }, ... } }

with per-entry failures recorded and enumeration continuing. Errors on the parent read_dir itself stay fatal — a directory you cannot open has no partial answer. Dired renders a footer line (N entries unreadable) and refuses wdired (§5).

name is optional (R2-2). A per-entry readdir iterator error (fs.rs:215-218) carries no filename — the entry never materialized, so there is nothing to name, and the error is wrapped with the parent path. That arm reports name = nil; the footer counts it without naming it. Every other per-entry arm has an entry in hand and names it.

What moves into the per-entry channel (F5): per-entry readdir errors, symlink_metadata failures, read_link failures (fs.rs:228-234), and non-UTF-8 symlink targets (:227). A non-UTF-8 target differs in kind from a non-UTF-8 name: the entry's own name is fine, the listing renders it with the target shown as unknown, and nothing needs to pass the target back through rename. As it stands today, one weird symlink in /tmp kills the entire listing — the exact failure class this section exists to fix.

Non-UTF-8 names stay fatal, and are a named deferral. Rendering them tolerantly is not a listing problem but a path representation problem: FsDirEntry.name is String, every pmacs.fs op takes a String path, and src/fs.rs:152-155 names byte-preserving paths as post-v0.1 work that widens the whole surface. Doing it properly changes the type of every path in the API; doing it improperly hands dired a name it cannot pass back to rename. Stage 1 reports the directory as unlistable with the offending bytes named, which FsError::NonUtf8Path already carries.

Why an opt rather than a shape change. read_dir already takes (path, opts), so it is signature-natural; it keeps the change additive for third-party packages; and it leaves the frozen fixture's bare-array consumption (init.lua:312) untouched, which matters because a proof artifact that must be edited to accommodate new work is not frozen.

The opts parser must validate (minor c). supersede_key (fs.lua:73-83) reads only opts.supersede and silently ignores every other key, so a typo'd tolerant would degrade to fatal mode with no signal. The tolerant change adds unknown-key rejection to the read ops' opts parsing.

Already tolerated, for the record: a failing metadata.modified() yields mtime 0 rather than an error (fs.rs:463-476), so the per-entry channel is not the first such concession — it is the first explicit one.

9. Keybindings, display, and the major mode (Q#DR7, Q#DR8, Q#DR10)

Global (both unbound repo-wide):

  • C-x ddired — prompt for a directory, defaulting to the current buffer's directory. Takes the standard display = "current" | "panel" opt (Q#BP11b), defaulting to "current" in Stages 12 like every other adopter.
  • C-x C-jdired-jump — dired on the current buffer's file's directory, cursor seated on that file.

Visit routing (Q#DR10, F2). A RET on a file line goes through pmacs.window.display_file(path, { select = true }), never bare find_or_open. find_or_open switches the active window in both branches before firing hooks (window_panel.rs:373-376), so a RET in a panel-displayed dired would replace the panel with the visited file — the panel swallows itself. display_file is the Q#BP11b answer: it dedups side-effect-free through the normalizing find_buffer_for_path before any I/O, resolves the destination before the read, and is what LSP visits and compile already use. Underneath, its panel-safety comes from display_target (window_panel.rs:425) — "the non-side window a visit from a panel should address".

Directory descent routes differently, and deliberately (R2-3). A RET on a directory line replaces the dired buffer in the window dired already occupies: switch_buffer when dired is in a document window, and pmacs.window.display(buf, { side = <same side>, select = true }) when dired is panel-displayed. This is Emacs behavior — walking a tree in a side window keeps the side window — and it is the opposite routing from a file visit for a principled reason: a file is not a dired buffer and belongs in the document area (hence display_target), while the next directory is the same kind of thing as the current one and belongs in the same slot. Dedication is a property of the slot, not the buffer, so a dedicated dired panel stays dedicated across descent and the new dired buffer inherits it; Stage 1's acceptance pins that rather than assuming it, since it is a Layout/WindowParams behavior this framing does not otherwise touch.

Mode-scoped on dired (Q#DR8: scope = "mode", mode = "dired", bound once at load rather than per buffer — dired is the first real consumer of #129's mode keymaps beyond language detection):

Key Command Stage
RET, f visit (dir → descend, file → display_file) 1
^ parent directory 1
n / p, <down> / <up> move by line 1
g revert (re-read, preserve cursor and marks) 1
q quit (restore previous buffer / window.quit in a side window) 1
s cycle sort mode (name → mtime → size) 1
m / u / U / t mark / unmark / unmark-all / toggle 2
d / x flag for deletion / execute flagged 2
D delete now (confirms) 2
R / C / + rename / copy / mkdir 2
w copy filename to the kill ring 2
C-x C-q toggle wdired 3

Mode-scoped on wdired: C-c C-c commit, C-c C-k abandon — matching compile's buffer-local C-c C-k idiom without colliding with it, since dired buffers are never compilation buffers.

Everything follows the M-; / M-% / C-c @ precedent of shipping the faithful Emacs default; users rebind through pmacs.keymap.

Cursor preservation (Q#DR9) is a Stage 1 requirement, not a nicety: g, a sort, and every Stage 2 operation repaint wholesale, and a dired that drops you to line 0 after each mark is unusable. The cursor is re-seated by basename, falling back to the nearest surviving line index when the file is gone — pmacs.editor.move_to_line (mod.rs:12526) makes this exact rather than the move_down-in-a-loop walk listview.lua:68-74 uses.

10. Staging and scope

  • Stage 0 (separable, recommended first) — find-file (Q#DR11). C-x C-fpmacs.minibuffer.read with source = "files" and source_root set to the current buffer's directory, accepting free text (resolve_accepted_value, minibuffer.rs:564) into pmacs.window.display_file. Completion is flat and does not descend: a function source cannot list a directory (it is called with zero arguments and cannot await, F3), and the Rust Files source is single-directory and 1024-capped. Typing a full path still works via free-text accept; typing a prefix completes only within the root. Hierarchical completion is a named Rust change (§13) — either pass the current input to custom sources, or re-root the Files source per keystroke. A nonexistent path creates a [new file] buffer rather than erroring: display_file routes through resolve_target_buffer (editor_core.rs:885-898), which on ErrorKind::NotFound creates the buffer, binds the path, and sets that status. This is Emacs parity and is stated rather than inherited (R2-6). Stage 0 carries its own acceptance (§14) — it is small but no longer trivial to describe honestly, which is itself an argument for taking it as its own PR. It is not required by any later stage; Stage 1's RET opens files directly. Say if you want it folded into Stage 1 instead; it is one branch either way.
  • Stage 1 — the dired view. Approval-critical. builtin/runtime/dired.lua; the dired major mode and mode keymap; buffer-per-directory with canonical naming and the ownership check; read-only intercept + round-trip input; visit routing through display_file; parent / sort / revert / quit; C-x d (with the display opt) / C-x C-j; cursor preservation across repaint; the dired.kill-when-opening config key; and the tolerant read_dir opt plus its unknown-key validation (Q#DR6) — the only Rust in this stage. No wire change; no protocol bump.
  • Stage 2 — marks and operations. The mark column and basename-keyed mark set; m/u/U/t/d/x/D/R/C/+/w; pmacs.fs.mkdir / copy / remove_dir_all; the prefix-aware, normalized rename rebind in the completion drain and the matching apply_resource_op raw-lookup fix (Q#DR5), pinned by a no-await rename and by a directory rename that must not strand the buffers beneath it; a y_or_n confirm helper; and removal of the Lua canonicalization mirror if Stage 1 shipped one (Q#DR2).
  • Stage 3 — wdired. C-x C-q mode swap; the column-classifying intercept over the mark-shifted layout; escape/unescape round-trip; duplicate-name and NUL/slash rejection pre-syscall; two-phase rename; field-by-field external-change detection; the symlink perms and symlink target rules; partial-application reporting.
  • After Stage 3 — shrink the M8 fixture to the minimum payload that still proves package universality (§3).

Stages 2 and 3 are sketched here and each gets its own detailed framing after the prior stage lands, per the folding-arc precedent. This framing asks approval for the architecture and Stage 1's detail.

11. Numbered decisions

  • Q#DR1 Dired ships as builtin/runtime/dired.lua, written fresh; the M8 fixture stays frozen under tests/fixtures/ because the harness routing (46/47 tests reaching dired through install_local
    • require) is the universality proof and dies if re-pointed, and because the builtin diverges structurally. Its 45 behavioral assertions are re-pinned by §14. Shrinking the fixture is scheduled after Stage 3. (§3)
  • Q#DR2 One buffer per directory, *dired:<canonical abs path>*, found-or-created by name; navigation opens the target's buffer rather than renaming the current one (there is no pmacs.buffer.set_name). Names and lookups are lexically normalized (tilde, absolutize, ./.., trailing slash) with symlinks deliberately unresolved; found-by-name verifies dired ownership before painting. A Lua mirror of normalize_buffer_path is a second canonical form and requires a parity acceptance against the Rust normalizer; exposing the normalizer instead is the preferred end state, since Q#DR5 gives it a second consumer. dired.kill-when-opening (default false) mirrors Emacs 28's opt-out. (§4)
  • Q#DR3 Read-only via add_intercept + set_round_trip_input, with dired's own paints using bypass_intercept; dired owns its buffer and does not extend pmacs.listview; wdired is a major-mode swap, and refuses to open on a partially-listed directory. (§5)
  • Q#DR4 Mark column at column 0 shifts all offsets; marks are keyed by basename, never line index; * and D are the two mark characters; a vanished basename is dropped from a batch and reported. (§6)
  • Q#DR5 Stage 2 adds pmacs.fs.mkdir / copy / remove_dir_all. Rename rebinding is fixed at the primitive (zero production callers to break), prefix-aware (a directory rename must not strand the buffers beneath it), normalize-before-lookup (fixing apply_resource_op's raw find_by_path in the same change), and in the main-thread completion drain AsyncRuntime::tick — never in the take/await path, which a fire-and-forget rename never reaches. Because rename settles as an undifferentiated ReplyKind::FsUnit, the drain keys on the pending job's JobKind::FsRename and the job retains from/to. R on a directory is supported in v1; C refuses directory sources. Destructive ops confirm via a new y_or_n helper. (§7)
  • Q#DR6 read_dir becomes per-entry tolerant behind an opt ({ tolerant = true }), carrying per-entry readdir/lstat/readlink failures and non-UTF-8 symlink targets in an errors channel; parent-level failures stay fatal; non-UTF-8 names stay fatal, deferred to a byte-preserving path surface. The read ops' opts parsing gains unknown-key rejection. (§8)
  • Q#DR7 Emacs-parity bindings: global C-x d (with the display opt) / C-x C-j; mode-scoped in-buffer keys per the §9 table; wdired on C-x C-q with C-c C-c / C-c C-k. (§9)
  • Q#DR8 Keys are mode-scoped (scope = "mode", mode = "dired"), not buffer-local — bound once at load, and the wdired swap changes the whole keymap with the mode. Dired is #129's first non-detection consumer. (§9)
  • Q#DR9 Cursor is re-seated by basename after every repaint, falling back to the nearest surviving index, via pmacs.editor.move_to_line. (§9)
  • Q#DR10 File visits route through pmacs.window.display_file (panel-safe via display_target), never bare find_or_open, which switches the active window before hooks and would let a RET replace the panel dired is displayed in. Directory descent instead reuses dired's own windowswitch_buffer in a document window, display { side = <same side>, select = true } in a panel — because the next directory is the same kind of thing as the current one. Dedication is a slot property and follows across descent. (§9)
  • Q#DR11 Stage 0's completion is the flat Rust Files source rooted at the current buffer's directory, plus free-text accept. Function sources cannot descend (zero-argument call, no await in the dispatch context); hierarchical path completion is a named Rust deferral. Amended by S0-1: the two are not independent — a selected candidate shadows typed text, so free-text accept is reached only when the input filters every candidate away (in practice, when it contains a /). The prompt field therefore starts empty (S0-2), and a leading ~ is expanded Lua-side (S0-3). (§0, §10)

12. Bets

  • B1 The fixture's hard parts — escaping with an exact inverse, two-phase rename, field-by-field external-change detection, the symlink rules — transfer to the builtin as decided design. FALSIFIABLE at Stage 3: if the mark-shifted layout or the mode swap forces a different commit model, the fixture stops being a reference and Stage 3 is re-framed from scratch.
  • B2 Tolerant read_dir is the only Rust change Stage 1 needs — display_file, mode keymaps, move_to_line, and set_round_trip_input all already exist. FALSIFIABLE during implementation; the most likely miss is scroll preservation, since the daemon owns view_top and "viewport facts on the wire" is a standing backlog gap — if preserving scroll (not just cursor) across a repaint needs a new fact, Stage 1 grows.
  • B3 Mode-scoped single-key bindings survive both frontends, because set_round_trip_input keeps optimistic-apply off — and, when dired sits in a panel, the !window.is_side() arm of the same gate (editor.rs:818) covers it a second time. FALSIFIABLE on a real GPU session: pressing d must flag, never insert.
  • B4 Buffer-per-directory does not produce clutter users complain about (Emacs parity), and dired.kill-when-opening is a sufficient escape hatch. Falsifiable only by use.
  • B5 Flat, non-descending completion is acceptable for Stage 0 because free-text accept covers the full-path case. FALSIFIABLE immediately by use: if typing full paths blind is what people actually do, hierarchical completion stops being a deferral and becomes Stage 0's real scope.

13. Deferred (named)

  • Hierarchical path completion — pass the current minibuffer input to custom sources, or re-root CompletionSource::Files per keystroke (Q#DR11).
  • Typed text vs. a selected candidate on accept (S0-1) — prefer the typed contents when they differ from the selection and the user has not explicitly moved it. Closes Stage 0's "a new bare name that is a subsequence of an existing entry opens the existing file" hole, but changes M-x and switch-buffer accept semantics too, so it needs its own reasoning and gates. It would also close the empty-input case (S0-4): fuzzy_score returns Some(0) for an empty needle (minibuffer.rs:637-640) and filter_and_sort breaks ties lexicographically (:678), so accepting immediately opens the first-sorted entry — dotfiles first, and possibly a directory, which then fails and reports. Inherited from the shared minibuffer, not introduced by find-file, and recorded as decided rather than overlooked.
  • Minibuffer history stores the pre-join value (S0-5) — Minibuffer::accept pushes the resolved value (a bare basename, or a root-relative path) into the history bucket Rust-side, before Lua joins it onto the root. So recalling sub/inner.txt with C-p under a different root resolves against the new root, and can silently create a [new file] buffer somewhere else. Emacs's file-name-history stores absolute paths. Lua cannot fix this — the push happens before on_accept runs — so it belongs with the other Rust-side minibuffer deferrals here.
  • Load through the normalized path (S0-3) — get_or_load_buffer computes a normalized path and then loads from the raw one (editor_core.rs:842-856), so tilde paths dedup but do not load. Same normalize-before-lookup family as Q#DR5's apply_resource_op fix.
  • Non-UTF-8 filenames — needs byte-preserving pmacs.fs paths (src/fs.rs:152-155); widens every path in the API. (Non-UTF-8 symlink targets are handled in Stage 1, §8.)
  • Shrinking the M8 fixture — scheduled after Stage 3 (§3).
  • Factoring the shared panel disciplines out of dired and pmacs.listview (§5).
  • o / C-o visit-in-other-window — the GPU has no splits (GPU structural parity, roadmap Arc 8).
  • ! / & shell command on marked files; Q query-replace across marked files; A search across marked files.
  • i insert-subdirectory (in-buffer recursive listing) and dired-hide-details.
  • Owner and group columns — no uid/gid → name primitive exists.
  • Human-readable sizes; sort by extension; reverse-sort toggle.
  • %m / %d regex mark family; dired-omit-mode.
  • Recursive copy (C on a directory), which v1 refuses.
  • Auto-revert on external change — pmacs.fs.watch exists and polls (fs.lua:226), so this is wiring plus a policy decision about polling a directory the user is not looking at.
  • Dired buffers in the desktop session — covered by Arc 3's standing "non-file buffers in the desktop" deferral.
  • Remote / Tramp-style paths.
  • Symlink creation and symlink-target editing (the fixture rejects target edits at commit; init.lua:821-840).

14. Acceptance

Stage 0 — find-file (R2-6)

0a. Flat completion within the root. With the current buffer in a temp directory, C-x C-f offers that directory's entries as candidates and does not offer entries of a subdirectory — documenting the flat-source limitation as intended behavior rather than leaving it unpinned. 0b. Free-text accept of a deeper path. Typing a full path below the root and accepting opens that file, with no candidate selected (resolve_accepted_value's raw-input path). 0c. Nonexistent path creates. Accepting a path that does not exist yields a buffer bound to it, unmodified and empty, with the [new file] status — not an error. 0d. No-path origin. From a buffer with no backing path, the prompt roots at the process cwd rather than erroring or offering nothing.

Stage 1 — the dired view

  1. Listing shape. C-x d on a temp directory renders a header line plus one line per entry, with kind char, perms, size, mtime, and name; a symlink renders l with -> target; the entry count matches read_dir.
  2. Visit dispatches on kind, through the panel-safe primitive (Q#DR10). RET on a subdirectory line opens that directory's dired buffer; RET on a file line opens the file bound to its path (the fixture's "not yet exposed" error is gone). RET on the header does nothing. The panel case is the real assertion: with dired opened display = "panel", a RET on a file line leaves the dired panel alive and puts the file in the document window — falsified by swapping display_file for find_or_open, which must make the panel disappear.
  3. Buffer-per-directory and canonicalization (Q#DR2). Descending twice then ascending twice yields the same buffer ids as the first visit; every dired buffer's name matches the directory it displays; and C-x d on /tmp, /tmp/, and /tmp/../tmp (with a real temp dir) yields one buffer, not three. With dired.kill-when-opening = true, the departed buffer is gone. 3b. Canonicalization parity (R2-4). One shared edge-case list — //tmp, a trailing slash, ~ with HOME set and unset, ./.. segments including a .. that would escape root, a relative path — driven through both dired's canonicalizer and the Rust normalize_buffer_path, asserting identical output. If Stage 1 exposes the normalizer instead of mirroring it, this degenerates to a round-trip test and the mirror-removal follow-up is dropped. 3c. Panel descent (Q#DR10, R2-3). With dired opened display = "panel", RET on a directory line leaves dired in the same side window showing the new directory — the panel is neither replaced by a document window nor duplicated — and a dedicated panel is still dedicated afterward.
  4. Ownership check (Q#DR2, F7). A foreign pmacs.buffer.create buffer named exactly *dired:<path>* is not adopted: C-x d on that path leaves the foreign buffer's contents byte-identical and opens dired elsewhere.
  5. Read-only (Q#DR3). A buffer.self-insert into a dired buffer is rejected by the intercept and leaves the text byte-identical; dired's own repaint succeeds through bypass_intercept. set_round_trip_input is set, pinned through the real dispatch path so a semantic frontend's d reaches the binding rather than optimistic-applying — falsified by reverting the set_round_trip_input call, not by a direct-call assertion.
  6. Mode keymap (Q#DR8). The keys resolve through scope = "mode" with no per-buffer binding: a second dired buffer, created without any keymap.bind call of its own, still responds to g and ^. pmacs.buffer.major_mode(buf) is "dired", and the mode shows in the statusline.
  7. Cursor preservation (Q#DR9). With the cursor on entry k, g re-seats on the same basename after an external file was added above it (so the line index changed); when that basename is deleted externally, the cursor lands on the nearest surviving line, not line 0.
  8. Sort. s cycles name → mtime → size → name; mtime sorts newest first and size largest first, each with a stable name tiebreak; the cursor stays on its basename across the reorder.
  9. Tolerant listing (Q#DR6). In a directory containing a child whose lstat fails, { tolerant = true } returns the surviving entries plus one errors row naming the child; dired renders every readable entry plus the unreadable-count footer; and the default (non-opt) call still returns a bare array — both forms called in one test, so the fixture's contract cannot regress unnoticed. A failure on the parent directory itself is still fatal.
  10. Tolerant symlink targets (Q#DR6, F5). A directory containing a symlink whose target is non-UTF-8 lists successfully under { tolerant = true }, with that entry present and its target reported unknown — falsified by reverting the read_link/target arm, which must take the whole listing down.
  11. Unknown opts rejected (minor c). read_dir(path, { tolerat = true }) errors naming the unknown key rather than silently listing in fatal mode.
  12. Non-UTF-8 names stay fatal, and say so. A directory containing a non-UTF-8 name reports the structured NonUtf8Path error with the offending bytes; dired surfaces it as a status message and creates no buffer.
  13. dired-jump. From a file buffer, C-x C-j opens dired on that file's directory with the cursor on that file's line. From a buffer with no path, it reports that and creates nothing.
  14. Quit. q restores the previously active buffer; in a side window (display = "panel") it routes through pmacs.window.quit, matching listview.quit's Q#BP11b split.
  15. Failure leaves nothing behind. C-x d on a nonexistent or unreadable directory creates no buffer, switches no window, and reports the reason — the fixture's dired_open_failure_leaves_editor_unchanged invariant.
  16. Scale. A 10,000-entry directory renders within the fixture's established 200 ms budget, on the builtin path — carrying the same cfg_attr(target_os = "macos", ignore) gate the fixture's version uses (m8_2_acceptance.rs:211-213), since hosted macOS debug runners do not consistently satisfy it.
  17. The fixture still passes. m8_2_acceptance 15/15 and m8_3_acceptance 32/32 unchanged, proving the read_dir opt is additive.

Every behavioral claim above is bite-verified with scripts/bite.

15. Gates (Stage 1)

cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings as its own step; cargo test --lib; cargo test --lib --features crdt; tests/dired_acceptance.rs (default + CRDT); tests/m8_1_acceptance.rs, tests/m8_2_acceptance.rs, and tests/m8_3_acceptance.rs (the additivity proof — m8_1 because it exercises pmacs.fs.rename and read_dir directly); cargo test --test m4_acceptance -- --skip basedpyright; PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu; the workspace sweep with an isolated XDG_CONFIG_HOME (the real ~/.config/pmacs/init.lua on this desktop calls install_local, which races every editor the sweep builds and leaks a status message into frame-comparing suites); git diff --check.

16. Branch and PR plan

Branch dired, worktree ../pmacs-dired-arcnot ../pmacs-dired, which would read as the fixture. Based on canonical githubsucks/main @ 0827dd1 (Lean 4 Stage 1 #160), which is one merge ahead of the scout's e745068; see §2's base note for why that movement does not disturb the ground truth.

The shared checkout is not the place to cut this. It currently has lean4-stage1 checked out with in-progress foreign work (src/highlight.rs modified), and this framing is untracked in it. Per the §5 ops rule, the branch is cut as a sibling worktree off main and the framing is committed there as the branch's first commit, rather than by switching the shared checkout. The framing does not travel until that commit is pushed.

Stage 1 implements on the same branch and opens as the first dired PR. Stages 2 and 3 are separate branches and PRs off the main that results from the prior stage, each with its own detailed framing. If Stage 0 (find-file) is taken separately it goes first, on its own branch find-file, and Stage 1 rebases onto the resulting main.