pmacs/docs/discovery-stage1-command-fa...

27 KiB

Discovery Stage 1 — the describe/list command family

Status: framing, rev 6 — final review corrections applied; ready for implementation approval. Serves COHERENCE.md §5 (unify discoverability), §1.1 (substrate without surface), §20 Priority 4.

0. Revision history

  • rev 6 (2026-07-31) — final review corrections applied.
    • The second-prompt census now includes help.describe-command. Six of the eleven canonical commands take an argument, not five: describe-command, describe-setting, describe-key, describe-hook, where-is, and apropos. The full M-x driver must accept each command's second prompt before it has tested the command.
    • The negative substring pin now has a discriminating fixture. It registers test.apropos-subsequence-fixture with a description whose qzjx letters occur only as the non-contiguous sequence q z j x, then first asserts that no registered command name or description contains qzjx as a substring. help.apropos qzjx finding nothing therefore fails under a fuzzy implementation rather than passing as an ordinary no-match.
    • The index-property arithmetic follows the eleven-command family. Its targeted mutation is now adding a twelfth canonical command without indexing it.
  • rev 5 (2026-07-31) — Q#D2 and Q#D3 answered by the user; no review findings at 1cc9d96.
    • Q#D2 → help.* is canonical, with two forwarders. editor.describe-command and editor.describe-setting are renamed to help.* and the old names retained as thin forwarders. The family is eleven commands under one prefix, so typing help at M-x surfaces all of it — which is the arc's entire purpose. The split surface rev 3 flagged as "the one outcome that should not survive review" is gone.
    • Q#D3 → apropos matches by substring, stated in its own description. fuzzy_score is subsequence-based and descriptions are long sentences, so a short query's letters almost always appear in order — fuzzy would match nearly every command and destroy the precision that makes apropos worth having.
  • rev 4 (2026-07-31) — review round 3. Two factual corrections, both accepted.
    • The custom source does not control display order. Rev 3 justified table.sort by claiming Custom candidates appear in return order; recompute_candidates immediately runs filter_and_sort, which ranks by fuzzy score and tie-breaks lexically. The sort is kept for a reason that is true — .take( CANDIDATE_LIMIT) is applied to the filtered iterator before the sort, so pool order decides which candidates survive truncation.
    • Read-only would not mitigate the foreign-*help* collision. Rev 3 said it would. A user-created buffer of that name has no intercept of ours; the renderer finds it by name and clears it regardless. The missing guarantee is ownership identity, the thing listview and dired both have.
  • 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 did. resolve_accepted_value (src/minibuffer.rs:564-575) returns the literal typed text whenever session.selected is None, so a non-matching typo still reaches on_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_interactive is 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 dispatch M-xeditor.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.rs has 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-settings while §3.1 listed nine; §2.5's "two sites into ten" should have been eleven; pmacs.keymap.lookup does not return description (it calls key_info_table with cmd = None, mod.rs:6938-6940); and the has_predicate / raw-predicate sites rev 1 cited are MenuItem fields, not Command.predicate. The predicate conclusion survives on correct evidence (§2.4).
  • 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).

Eleven commands each calling show_help_text directly would make it an eleven-site migration (nine new, plus the two renamed ones that call it today). §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 — eleven commands under one prefix

Nine new, all rendering data that already exists:

Command Reads
help.describe-key pmacs.describe.key — prompts for a chord, resolved against the active buffer + mode
help.describe-mode pmacs.describe.mode for the active buffer
help.describe-buffer pmacs.describe.buffer
help.describe-hook pmacs.describe.hook
help.where-is describe.command(name).key_bindings
help.list-commands command.list() + each description
help.list-keybindings keymap.list(), grouped by scope
help.list-settings config.list()
help.apropos substring over names and descriptions (Q#D3)

Two renamed, so the family is not split:

Command Was
help.describe-command editor.describe-command
help.describe-setting editor.describe-setting

Naming — decided (Q#D2). help.* is canonical. #205 established help as the index, so the family it indexes shares its prefix, and typing help at M-x now surfaces the whole family rather than only the index. That grouping is the discoverability win this arc exists for.

The two old names become forwarders, not exceptions. editor.describe-command and editor.describe-setting remain registered and invoke their help.* counterparts, so nothing a user has in muscle memory or that docs/keybindings.md names stops working.

Two forwarders are a real, bounded cost — two names for one thing is the duplication §5 complains about. They are accepted because the alternative is breaking documented commands, and they carry a deprecation path a later stage can take. What is not accepted is a split family, which is what rev 3 shipped and rev 5 removes.

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 existing no 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, but the source needs a mapper. parse_completion_source (mod.rs:14145-14165) accepts none / commands / buffers / files and a Lua FunctionCompletionSource::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:

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 for deterministic pool construction, not for display order — rev 2's stated reason was false. recompute_candidates hands the pool straight to filter_and_sort, which ranks by fuzzy score descending and breaks ties lexically (src/minibuffer.rs:672-680), so what the source returns never reaches the user in that order.

The sort still earns its place, for a subtler reason: filter_and_sort applies .take(CANDIDATE_LIMIT) to the filtered iterator before sorting, so when more than CANDIDATE_LIMIT settings match a needle, pool order decides which ones survive truncation. Registration order would make that selection vary with an unrelated config edit; sorting makes it reproducible.

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. 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 replacementbuf: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* hazardfind_or_create_help_buffer matches on the name, so a user's own buffer called *help* is adopted and 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.

And read-only would not fix the fourth policy either — rev 2 implied it would. A buffer the user created and named *help* carries no intercept of ours, so an intercept on the buffers we create protects nothing: the renderer still finds theirs by name and clears it. The missing protection is ownership identity — a private table of buffers this module created, so found-by-name is not adoption — which is exactly what listview (panels) and dired (its handle table) both carry and this mechanism does not. Naming the right missing guarantee matters, because the wrong one would send a later fix at the wrong layer.

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.

Six of the eleven canonical commands take an argument and open a SECOND prompt (describe-command, describe-setting, describe-key, describe-hook, where-is, 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

  1. N — each of the eleven 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*.
  2. N — where-is agrees with the keymap. Bind a command to a known chord, then assert where-is reports that chord. Falsified by rendering a static string.
  3. N — list-keybindings covers every binding keymap.list() reports. A property over the data, with a non-empty precondition so the loop cannot be vacuous.
  4. N — apropos matches descriptions, not only names, and does so by substring. Two assertions: a word appearing in exactly one command's description and no command name finds that command (what distinguishes apropos from a name filter); and a deliberately discriminating negative: register test.apropos-subsequence-fixture, whose description's qzjx letters occur only as the non-contiguous sequence q z j x, assert that no registered command name or description contains qzjx as a substring, then assert help.apropos qzjx finds nothing. A fuzzy implementation finds the fixture, so this pins Q#D3's substring decision rather than passing as an ordinary no-match.
  5. N — describe-setting completes, 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 leaves selected() nil, and accepting produces the no such setting status — not a described setting. Rev 1 asserted a typo "cannot reach on_accept", which resolve_accepted_value contradicts.
  6. N — M-x help lists the family. A property over the family list, so adding a twelfth canonical command without indexing it fails.
  7. P — every command's *help* write goes through _show_help. Replace that function with a counting stub, drive all eleven through §4.0's path, and assert the count equals eleven. Pins §3.4's actual claim — one owner for *help* writes — rather than the migration claim rev 1 overstated. The two renamed commands are in the count precisely because they were the pre-existing direct callers.
  8. P — the old names still work, as forwarders. Invoke editor.describe-command and editor.describe-setting through §4.0's M-x path and assert they render the same subject as their help.* counterparts. editor.list-buffers and editor.list-workers are untouched and asserted unchanged. Targeted mutation: dropping the forwarders after the rename — which is the failure a user with muscle memory would hit first.
  9. 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_interactive directly — otherwise it would pass even if M-x grew predicate filtering.

5. Deferred, each with its reason

  • Richer M-x rows. MinibufferPrompt.candidates is Vec<String> on the wire; CompletionPopupRow already carries kind/detail, so the pattern exists — but it is a protocol change and belongs to its own stage with a version bump.
  • Command gaining 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-h deletes 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 weigh F1 / C-c ? / a rebind together.
  • Settings value provenance (§11) — describe-setting will 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_value and 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 — decided

  • Q#D2 — help.* or editor.*? → help.* canonical, with two forwarders. Eleven commands under one prefix, so typing help at M-x surfaces the family. editor.describe-command / editor.describe-setting keep working as forwarders. The split surface rev 3 flagged is gone; the forwarders' duplication is the accepted, bounded price of not breaking documented names.
  • Q#D3 — should apropos fuzzy-match? → No, substring. fuzzy_score is subsequence-based and descriptions are long sentences, so a short query's letters almost always appear in order — fuzzy would match nearly every command. Substring is stated in the command's own description, and acceptance 4 pins it with a subsequence-that-is-not-a-substring finding nothing.

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