19 KiB
Discovery Stage 1 — the describe/list command family
Status: framing, rev 2 — awaiting review round 2.
Serves COHERENCE.md §5 (unify discoverability), §1.1 (substrate
without surface), §20 Priority 4.
0. Revision history
- 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
did.
resolve_accepted_value(src/minibuffer.rs:564-575) returns the literal typed text wheneversession.selectedisNone, so a non-matching typo still reacheson_accept— and a fuzzy match can instead select a different setting silently. Completion here is assistance, not validation. §3.2 is reframed and acceptance 5 now pins what actually happens; closed-set acceptance is named as Rust work in §5. invoke_interactiveis not the M-x path — the exact error #205 corrected, repeated one PR later. It rotates the interactive-command boundary and calls the body (mod.rs:6097-6110); it does not open a palette. The path is dispatchM-x→editor.execute-command→ accept a command →invoke_interactive. Acceptances 1, 7 and 9 are rewritten around it, including the second prompt for commands that take an argument._show_help(text)is an output sink, not a migration seam.src/help.rshas semantic renderers for command / key / buffer / mode / hook / view and none for settings, lists or apropos, so once Lua has flattened those to text a later migration still has to change each command's subject-specific logic. §3.4's claim is narrowed to what is true — one owner for Lua*help*writes — and §3.4a states the structure that makes the future Rust work per-subject rather than per-call-site.- Ground-truth and counting errors. §2.2 listed eight missing
commands and omitted
list-settingswhile §3.1 listed nine; §2.5's "two sites into ten" should have been eleven;pmacs.keymap.lookupdoes not returndescription(it callskey_info_tablewithcmd = None,mod.rs:6938-6940); and thehas_predicate/ raw-predicate sites rev 1 cited areMenuItemfields, notCommand.predicate. The predicate conclusion survives on correct evidence (§2.4).
- Completion does not close the free-text hole, and rev 1 said it
did.
- rev 1 (2026-07-31) — first framing. Scouted against
githubsucks/main@54a092e(Journey Stage 1b-3, #205).
1. Why this, and why now
COHERENCE.md §20 Priority 4 calls unified discovery "almost pure
wiring — the best payoff-per-effort in this document", and §5 grades
it "substrate without surface — the sharpest instance of §1.1".
Journey Stage 1b-3 (#205) just landed M-x help and documented it as
the root of this family: "when the discovery arc adds help.keys
and friends, help stays the index they are reached from, so no rename
is owed." This stage is that family. It also inherits 1b-3's deferred
question — the help prefix — with the constraint already recorded (§6).
Two journey steps are graded works but undiscoverable and move here
without any new machinery: step 7 (symbol search — M-./M-?/C-c o
bound but "advertised nowhere") and step 11 (*workers* — "no
keybinding, no indicator").
2. Ground truth
Read in the tree at 54a092e. The substrate is already there; this
stage adds no Rust.
2.1 What Lua can already ask
| Surface | Returns |
|---|---|
pmacs.command.list() |
every command name |
pmacs.describe.command(name) |
description, source, key_bindings (where-is, computed on demand) |
pmacs.describe.key(seq) |
resolved against the active buffer + major mode |
pmacs.describe.{buffer,view,mode,hook} |
structured tables |
pmacs.keymap.list() |
{ sequence, command, scope } for every binding, via KeymapStack::iter_all |
pmacs.keymap.lookup(seq) |
{ sequence, command, scope, source } — not description: it calls key_info_table with cmd = None (mod.rs:6938-6940), so the description arm never fires |
pmacs.config.list() / pmacs.config.describe(name, buf) |
full typed descriptors |
Every one of the commands in §3 is a rendering of data already
reachable, and CompletionSource::Custom(Function) means even the
prompts need no new Rust (§3.2). This stage adds no Rust at all.
That is what "pure wiring" means here, and it is worth stating precisely so the stage is not oversold: the work is surface, and the risk is in what the surface leaves out.
2.2 What exists as a command today
editor.describe-command, editor.describe-setting,
editor.describe-instance[-buffer], editor.list-buffers,
editor.list-workers, and help (#205).
Missing entirely — nine, matching §3.1 exactly: describe-key, describe-mode, describe-hook, describe-buffer, where-is, list-commands, list-keybindings, list-settings, apropos. (Rev 1 listed eight here and nine in §3.1.)
2.3 describe-setting prompts free-text, deliberately
-- builtin/commands/default.lua
pmacs.minibuffer.read {
prompt = "Describe setting: ",
history = "command", -- note: no `source`
on_accept = function(name) … end,
A typo yields a status-line error. describe-command does pass
source = "commands". The asymmetry is real and this stage closes it
(§3.2) — but note why it was skipped: dired's C-x d records that a
completion source makes RET-on-empty accept whatever sorts first, and a
selected candidate shadows typed text. That is a genuine trade, not an
oversight, so §3.2 says what changes about it.
2.4 Command.predicate is stored, exposed, and never evaluated
predicate: Option<Function> (src/command.rs:79) is read in exactly
two places: src/help.rs:76 — inside the orphaned renderer — and one
assertion past #[cfg(test)]. No production call site evaluates it:
not invoke, not invoke_interactive, not dispatch, not M-x filtering,
not the menu. Its doc comment describes palette gray-out that never
shipped (§24 already logs this).
(Rev 1 cited mod.rs:6204 / :6257 as evidence. Those are
MenuItem fields — item.label, item.group, item.order,
item.predicate — a different type with its own predicate. The
conclusion held; the evidence did not.)
This stage does not evaluate it either (§5), because doing so makes commands stop being invocable — a behaviour change needing its own decision about what "unavailable" means at each call site.
2.5 The help layer is duplicated, and this stage would deepen it
src/help.rs has render_command / render_key / render_buffer /
render_mode / render_hook / render_view plus link resolution, and
is orphaned — the reachable renderer is the Lua show_help_text,
which renders less (no source, no scope).
Nine new commands each calling show_help_text would turn a two-site
migration into an eleven-site one. §3.4 is the answer, and it is the
most consequential decision in this framing — but §3.4 is careful about
what it can actually promise, because src/help.rs has renderers for
command / key / buffer / mode / hook / view and none for settings,
lists, or apropos.
3. Design
3.1 The family
Nine commands, all rendering existing data:
| Command | Reads |
|---|---|
describe-key |
pmacs.describe.key — prompts for a chord, resolved against the active buffer + mode |
describe-mode |
pmacs.describe.mode for the active buffer |
describe-buffer |
pmacs.describe.buffer |
describe-hook |
pmacs.describe.hook |
where-is |
describe.command(name).key_bindings |
list-commands |
command.list() + each description |
list-keybindings |
keymap.list(), grouped by scope |
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.
3.2 describe-setting gains completion — which is assistance, not validation
Rev 1 claimed a completion source means "a typo cannot reach
on_accept". It does not.
// src/minibuffer.rs:564-575
fn resolve_accepted_value(session: &MinibufferSession, typed: &str) -> String {
if matches!(session.source, CompletionSource::None) { return typed.to_owned(); }
if let Some(idx) = session.selected
&& let Some(cand) = session.candidates.get(idx) { return cand.clone(); }
typed.to_owned() // <-- no selection: the literal typed text
}
So with a source attached there are two outcomes rev 1 conflated:
- No candidate selected (a typo matching nothing) → the literal text
reaches
on_accept, exactly as today, and the existingno such setting: <name>status path handles it. - A candidate selected → that candidate wins over the typed text. On a fuzzy source a near-miss can therefore silently describe a different setting — a new failure mode, milder than the old one but not nothing.
What the source genuinely buys is assistance: the closed set is
visible and reachable by completion instead of having to be known. That
is worth doing and is what §3.1 promises. Closed-set acceptance
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.
3.3 M-x help becomes the index
#205 shipped help as a static cheat sheet. It now lists the family
above, so the arc's own promise — help stays the index they are
reached from — is kept rather than merely restated.
3.4 One owner for Lua *help* writes — the honest version of the claim
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 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,
buffer, mode, hook and view — and none for settings, lists, or
apropos. _show_help takes already-flattened text, so by the time a
subject reaches it the structure a richer renderer would need is gone.
A later migration still has to change each command's subject-specific
logic; the seam saves the plumbing, not the semantics.
3.4a The structure that makes the future work per-subject
So the funnel is paired with a shape that keeps the semantics
addressable: each command's rendering is a named per-subject
function returning text — render_key_help(info),
render_settings_list(rows) — and the command body does nothing but
call it and hand the result to _show_help.
Then the future help-unification stage is: replace each named renderer
whose subject src/help.rs already covers (key, mode, hook, buffer),
and write new Rust renderers for the three subjects it does not
cover (settings, lists, apropos). That work is enumerated here rather
than discovered later, which is the actual deliverable of this section.
Corollary this stage must respect: where the Lua renderer is poorer
than src/help.rs for a subject it does cover (no source, no scope),
the new commands render the poorer form rather than inventing a third
shape.
4. Acceptance
N = new behaviour, must fail on full revert. P = preservation, falsified by a named mutation.
4.0 The M-x path, stated once
Rev 1 said "driven through pmacs.command.invoke_interactive (the M-x
path)". That is not the M-x path, and #205 established as much one
PR earlier. invoke_interactive rotates the interactive-command
boundary and calls the body (mod.rs:6097-6110); it opens no palette.
Every pin below that claims to exercise a command as a user does drives:
dispatch M-x
→ editor.execute-command opens the minibuffer (source = "commands")
→ type the command name
→ assert pmacs.minibuffer.selected() == "<name>" -- BEFORE RET
→ dispatch RET -- accept
→ editor.execute-command calls invoke_interactive
The pre-RET assertion is not decoration: accept() does
session.take(), so afterwards nothing about the accepted value
survives, and a selected candidate shadows typed text.
Commands that take an argument open a SECOND prompt (where-is,
describe-key, describe-hook, describe-setting, apropos). Those
pins drive that prompt too, and assert against it with the same
pre-accept discipline. A pin that stops after the first RET has tested
the palette, not the command.
4.1 Pins
- N — each of the nine commands runs from M-x and renders content.
Through §4.0's full path, including the second prompt where the
command takes one. Asserts content produced in
*help*. - N —
where-isagrees with the keymap. Bind a command to a known chord, then assertwhere-isreports that chord. Falsified by rendering a static string. - N —
list-keybindingscovers every bindingkeymap.list()reports. A property over the data, with a non-empty precondition so the loop cannot be vacuous. - N —
aproposmatches descriptions, not only names. Search a word that appears in exactly one command's description and in no command name. This is what distinguishes apropos from a name filter. - N —
describe-settingcompletes, and a non-matching typo still reaches the existing error path. Two assertions, because §3.2 has two outcomes: (a) typing a real setting's prefix makes it the selected candidate, and accepting describes it; (b) typing a string that matches nothing leavesselected()nil, and accepting produces theno such settingstatus — not a described setting. Rev 1 asserted a typo "cannot reachon_accept", whichresolve_accepted_valuecontradicts. - N —
M-x helplists the family. A property over the family list, so adding a tenth command without indexing it fails. - P — every new command's
*help*write goes through_show_help. Replace that function with a counting stub, drive all nine through §4.0's path, and assert the count equals nine. Pins §3.4's actual claim — one owner for*help*writes — rather than the migration claim rev 1 overstated. - P — the existing describe/list commands still work.
editor.describe-command,editor.describe-setting,editor.list-buffers,editor.list-workersunchanged. Targeted mutation: renaming rather than retaining them. - P — no command's predicate is evaluated. Register a command whose
predicate raises, then run it through §4.0's full M-x path and
assert it runs. Pins §2.4's deliberate non-change, so a stage
that starts evaluating predicates must change this pin knowingly.
Driven through the palette, not
invoke_interactivedirectly — otherwise it would pass even if M-x grew predicate filtering.
5. Deferred, each with its reason
- Richer M-x rows.
MinibufferPrompt.candidatesisVec<String>on the wire;CompletionPopupRowalready carrieskind/detail, so the pattern exists — but it is a protocol change and belongs to its own stage with a version bump. Commandgaining title/category/aliases/flags/arg-schema. A Rust type change with ~147 definition sites; own stage.- Predicate evaluation (§2.4) — a behaviour change.
- Help-layer unification. The reason §3.4 exists; own stage, made cheaper by this one rather than harder.
- A help prefix key. #205 recorded the constraint in §18:
C-hdeletes a word because non-kitty terminals cannot disambiguate Ctrl+Backspace from Ctrl+H. This stage adds no keybindings at all, so the prefix decision is taken once, for the whole family, by the stage that can weighF1/C-c ?/ a rebind together. - Settings value provenance (§11) —
describe-settingwill still answer "who set this?" with the definition site. - Closed-set acceptance semantics (§3.2). Making a prompt refuse a
value that is not a candidate needs
resolve_accepted_valueand a per-session flag — Rust, and a change every existing prompt with a source would inherit. Named here because rev 1 claimed this stage delivered it as a side effect of adding completion.
6. Coherence impact
- Journey steps touched: 7 and 11, both graded works but
undiscoverable; this makes the existing bindings and the
*workers*view reachable by name. No step's grade flips on this stage alone — both also want keybindings, which §5 defers. - §5's grade moves from "substrate without surface" toward Partial: the surface exists for commands, keys, modes, hooks and settings; it does not yet exist for packages or workers (§13, §9). That is a §25 obligation on the landing PR.
- Interaction islands: none added. Everything renders into the one
*help*buffer through the one seam (§3.4) — this stage's main job is to avoid becoming the next island. - Config registry: not adopted. Nothing here is a tunable.
- Background-work attribution: unchanged.
7. Questions
- Q#D2 —
help.*prefix, or keepeditor.*? §3.1 proposeshelp.*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#D3 — should
aproposfuzzy-match?fuzzy_scoreexists (src/minibuffer.rs:637-666) and M-x already uses it. Substring is more predictable for a search command; fuzzy is more consistent with M-x. Recommended: substring, and say so in the command description.
8. Ledger
Branch discovery-stage1-commands, worktree ../pmacs-p4-discovery,
based on githubsucks/main @ 54a092e. Framing only; no code, no PR.
A sibling lane (test-ambient-isolation-impl) is in flight in another
worktree and touches src/editor.rs and tests/. This lane is
builtin/ plus its own suite, so the surfaces are disjoint; integrate
main late regardless.
git fetch githubsucks
git worktree add ../pmacs-p4-discovery \
-b discovery-stage1-commands \
githubsucks/discovery-stage1-commands