docs(discovery): framing revision 3 — close review round 2

Two blocking, two major. All four accepted.

The ledger lane still said revision 1 and still carried the two claims
revision 2 refuted. The cause is worth recording: revision 2's ledger
edit was an assert-then-write block whose later assertion failed, so
nothing before it was written either — while the commit message
reported the whole change as done. This is the second time in this
project that shape has silently dropped edits. The lane is rewritten
from scratch and the result verified by re-reading the file rather than
inferred from an exit code.

`names_from` does not exist. Revision 2's completion source called a
helper nobody has written, over `pmacs.config.list()`'s descriptor
tables where `CompletionSource::Custom` consumes a sequence of strings.
Opening the prompt would have raised on an undefined global. The mapper
is now specified, and sorted — `Custom` presents candidates in the
order returned, and registration order is neither stable nor useful.

`*help*` has no read-only intercept. Revision 2 claimed one while
`show_help_text` writes with plain delete/insert, and #205 had already
recorded that this mechanism has not adopted the generated-buffer write
invariant. §3.4 now names the policies that really are shared —
reuse-by-name, wholesale replacement, the `q` binding, and the
foreign-buffer hazard — and notes that the last is precisely what a
read-only intercept would have mitigated and does not.

The naming was underspecified. With no `help.describe-command` in the
table, calling the existing `editor.describe-*` commands
"aliases-by-retention" was wrong on both halves: nothing forwards to
them and there was nothing for them to alias. They are now explicit
exceptions, the resulting split surface is named as a wart, and Q#D2 is
sharpened to the two ways out — forward the two under `help.*`, or keep
the family `editor.*` throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
Levi Neuwirth 2026-07-31 18:08:58 -04:00
parent 9e79b6b9a3
commit aa932f9586
2 changed files with 128 additions and 39 deletions

View File

@ -380,41 +380,56 @@ which would have re-conflicted on every merge.
githubsucks/journey-stage1b3-welcome
```
## Discovery Stage 1 (P4) — FRAMING OPEN, revision 1
## Discovery Stage 1 (P4) — FRAMING OPEN, revision 3
- **Branch `discovery-stage1-commands`**, worktree `../pmacs-p4-discovery`,
based on `githubsucks/main` @ `54a092e`. **Framing only; no code, no
PR yet.** `docs/discovery-stage1-command-family-framing.md` revision 1.
PR yet.** `docs/discovery-stage1-command-family-framing.md` revision 3,
two review rounds closed (round 1: two blocking, two major; round 2:
two blocking, two major; all accepted).
- **What it is.** `COHERENCE.md` §20 Priority 4 — "almost pure wiring,
the best payoff-per-effort in this document". Nine describe/list
commands (describe-key/mode/hook/buffer, where-is, list-commands,
list-keybindings, list-settings, apropos) over introspection that
already exists.
- **It adds NO Rust.** `pmacs.describe.*`, `pmacs.keymap.list()`,
- **It adds no Rust.** `pmacs.describe.*`, `pmacs.keymap.list()`,
`pmacs.command.list()` and `pmacs.config.list()` already return
everything needed, and `parse_completion_source` accepts a **Lua
`Function`** (`CompletionSource::Custom`), so even the prompts need no
new Rust — which also closes `describe-setting`'s free-text hole.
- **The decision that matters: ONE rendering seam.** `src/help.rs`'s
renderer is orphaned and the reachable Lua `show_help_text` renders
less, so nine new commands calling it directly would turn a two-site
migration into a ten-site one. Everything routes through
`pmacs.editor._show_help` (the seam #205 added) so the later help
unification stays a one-site change — pinned by a counting stub, not
left as a convention. **Narrowed in revision 2:** the seam buys one
*owner for `*help*` writes*, not a one-site migration — `src/help.rs`
has renderers for command/key/buffer/mode/hook/view and **none for
settings, lists or apropos**, and `_show_help` takes already-flattened
text. Each command's rendering is therefore a named per-subject
function, so the future Rust work is enumerated per-subject instead of
discovered per-call-site.
new Rust.
- **Completion is ASSISTANCE, not validation.**
`resolve_accepted_value` returns the **literal typed text** whenever
no candidate is selected, so a non-matching typo still reaches
`on_accept` and the existing error path — and a fuzzy near-miss can
silently describe a *different* setting, a new failure mode.
Closed-set acceptance ("refuse a non-candidate") is Rust work and is
deferred. The custom source needs a **mapper**: `config.list()`
yields descriptor *tables* while `Custom` consumes a sequence of
strings.
- **`invoke_interactive` is NOT the M-x path** — the error #205
corrected, repeated one PR later. The path is dispatch `M-x`
`editor.execute-command` → assert the selected candidate **before**
RET (`accept()` does `session.take()`) → accept → `invoke_interactive`.
Five of the nine commands open a **second** prompt the pins must drive
too; a pin that stops after the first RET has tested the palette.
- **One owner for `*help*` writes — NOT a one-site migration.**
`src/help.rs` has semantic renderers for command/key/buffer/mode/
hook/view and **none for settings, lists or apropos**, and
`_show_help` takes already-flattened text, so a later migration still
changes each command's subject-specific logic. What the funnel buys is
the shared policy in one place: reuse-by-name, wholesale
delete+insert, the `q` binding, and the foreign-`*help*` hazard.
**`*help*` is ordinary editable content** — it has no read-only
intercept and no generated-content invariant. Each command's rendering
is a named per-subject function so the future Rust work is enumerated
per-subject (three new renderers) rather than discovered per-call-site.
- **Deliberately deferred, each with a reason:** richer M-x rows
(`MinibufferPrompt.candidates` is `Vec<String>` — protocol change);
`Command` gaining title/category/flags (~147 definition sites);
predicate evaluation (**stored and exposed but never evaluated** — a
behaviour change); help-layer unification; and the **help prefix key**,
which this stage does not touch because #205 recorded why `C-h` is not
free.
predicate evaluation (**read only at `src/help.rs:76` and one test** —
a behaviour change); help-layer unification; closed-set acceptance;
and the **help prefix key**, which this stage does not touch because
#205 recorded why `C-h` is not free.
- Recovery:
```sh

View File

@ -1,11 +1,34 @@
# Discovery Stage 1 — the describe/list command family
**Status: framing, rev 2 — awaiting review round 2.**
**Status: framing, rev 3 — awaiting review round 3.**
**Serves `COHERENCE.md` §5 (unify discoverability), §1.1 (substrate
without surface), §20 Priority 4.**
## 0. Revision history
- rev 3 (2026-07-31) — review round 2. Two blocking, two major; all four
accepted.
- **The ledger lane still said revision 1 and kept refuted claims.**
Rev 2's ledger edit **aborted on a failed assertion before writing**,
so only one paragraph of it landed while the commit message reported
all of it. The lane is rewritten from scratch and the result was
verified by re-reading the file, not inferred from an exit code.
*(Second occurrence of this failure mode in this project — an
assert-then-write block discards every earlier edit in the block.)*
- **`names_from` does not exist.** Rev 2's completion source called a
helper nobody has written, over `config.list()`'s descriptor
**tables** where `Custom` wants a sequence of **strings** — the
prompt would have raised on an undefined global the first time it
opened. §3.2 now specifies the mapper.
- **`*help*` has no read-only intercept.** Rev 2's §3.4 claimed one.
`show_help_text` writes with plain `delete`/`insert` and #205
recorded that this mechanism has not adopted the generated-buffer
write invariant. §3.4 now names the four policies that are actually
shared.
- **The naming was underspecified.** The nine-command table contains
no `help.describe-command`, so calling the `editor.*` commands
"aliases-by-retention" was wrong on both halves. They are now stated
as explicit exceptions, and Q#D2 is sharpened to the two ways out.
- rev 2 (2026-07-31) — review round 1. Two blocking, two major; all four
accepted, all four verified in the code first.
- **Completion does not close the free-text hole**, and rev 1 said it
@ -162,12 +185,24 @@ Nine commands, all rendering existing data:
| `apropos` | substring match over **names and descriptions** |
| `list-settings` | `config.list()` |
**Naming.** They are `help.*`-prefixed (`help.describe-key`,
`help.where-is`, …) with the existing `editor.describe-*` kept as
aliases-by-retention, not renamed. #205 established `help` as the index;
this makes the family's identity match. Existing names are not removed
`editor.describe-command` is bound in muscle memory and in
`docs/keybindings.md`.
**Naming, stated exactly.** The nine **new** commands are `help.*`
(`help.describe-key`, `help.where-is`, …). #205 established `help` as
the index, so the family it indexes shares its prefix.
**The two pre-existing commands are intentional exceptions, not
aliases.** `editor.describe-command` and `editor.describe-setting` keep
their names and are **not** duplicated under `help.*` — rev 1 called
them "aliases-by-retention", which was wrong twice over: nothing
forwards to them, and the nine-command table never contained a
`help.describe-command` for them to be aliases *of*.
So the shipped surface is nine `help.*` commands plus two `editor.*`
ones covering the same family. **That asymmetry is a wart**, and it is
Q#D2's whole subject: either the two get `help.*` names with the old
ones forwarding (eleven commands, two forwarders), or the family stays
`editor.*` throughout (nine commands, no new prefix). This framing does
not decide it, because a rename that this arc's later stages would
revisit is worse than an explicit exception recorded for one round.
### 3.2 `describe-setting` gains completion — which is assistance, not validation
@ -201,10 +236,29 @@ semantics — "refuse a value that is not a candidate" — is Rust work**
(`resolve_accepted_value` and a per-session flag) and is deferred to
§5 rather than smuggled in as a side effect.
**Still no Rust in this stage.** `parse_completion_source`
(`mod.rs:14145-14165`) accepts `none` / `commands` / `buffers` / `files`
**and a Lua `Function`** → `CompletionSource::Custom`. The source is
`function() return names_from(pmacs.config.list()) end`.
**Still no Rust in this stage**, but the source needs a mapper.
`parse_completion_source` (`mod.rs:14145-14165`) accepts `none` /
`commands` / `buffers` / `files` **and a Lua `Function`**
`CompletionSource::Custom`, and `Custom` consumes a **sequence of
strings**. `pmacs.config.list()` returns descriptor **tables**, so
handing it over directly would not typecheck — and rev 1 wrote
`names_from(...)`, **a helper that does not exist**; the prompt would
have raised on an undefined global the first time it opened.
The mapper is three lines and belongs to this stage:
```lua
source = function()
local names = {}
for _, d in ipairs(pmacs.config.list()) do names[#names + 1] = d.name end
table.sort(names)
return names
end,
```
Sorted because `Custom` candidates are presented in the order returned,
and `config.list()`'s order is registration order — which is neither
stable across a config edit nor useful to a reader.
### 3.3 `M-x help` becomes the index
@ -218,10 +272,25 @@ Every new command renders through **`pmacs.editor._show_help`** — the
seam #205 added — and **not** by calling `show_help_text` directly or
building its own buffer.
**What that buys, precisely: one owner for `*help*` writes.** Buffer
ownership, the read-only intercept, `q`, and the found-by-name hazard
are decided in one place instead of eleven. That is real and it is the
reason to do it.
**What that buys, precisely: one owner for `*help*` writes.** Four
shared policies get decided in one place instead of eleven:
- **reuse-by-name** — a single `*help*` buffer found by name and reused
across invocations;
- **wholesale replacement**`buf:delete(0, len)` then `buf:insert(0,
text)`, never a diff, because `*help*` is reflowed per subject;
- the **`q` binding**, rebound per fresh buffer;
- the **foreign-`*help*` hazard** — found-by-name is not ownership, so a
user's buffer of that name is cleared.
**`*help*` is ordinary editable content.** Rev 1 wrote "the read-only
intercept"; there isn't one. `show_help_text` writes with plain
`delete`/`insert`, the buffer keeps its undo history, and #205 already
recorded that this mechanism has **not** adopted the generated-buffer
write invariant. Saying otherwise would have had this stage claim a
guarantee it does not provide — and the fourth policy above is exactly
the hazard that a read-only intercept would have mitigated and does
not.
**What it does not buy, and rev 1 claimed it did:** a one-site migration
to `src/help.rs`. That layer has semantic renderers for **command, key,
@ -365,9 +434,14 @@ the palette, not the command.
## 7. Questions
- **Q#D2 — `help.*` prefix, or keep `editor.*`?** §3.1 proposes `help.*`
with the old names retained. The counter-argument is that two names
for one thing is exactly the duplication this arc exists to remove.
- **Q#D2 — `help.*` prefix, or keep `editor.*`?** §3.1 ships nine
`help.*` commands and leaves `editor.describe-command` /
`editor.describe-setting` as **exceptions**, so the family is split
across two prefixes. Resolve it one of two ways: give those two
`help.*` names with the `editor.*` ones **forwarding** (eleven
commands, two forwarders, one canonical prefix), or drop `help.*` and
keep the family `editor.*` throughout (nine commands, no new prefix).
A split surface is the one outcome that should not survive review.
- **Q#D3 — should `apropos` fuzzy-match?** `fuzzy_score` exists
(`src/minibuffer.rs:637-666`) and M-x already uses it. Substring is
more predictable for a search command; fuzzy is more consistent with