# 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-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.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 ```lua -- 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` (`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.** ```rust // 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: ` 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 `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 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 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** — `find_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() == "" -- 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` 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. ```sh git fetch githubsucks git worktree add ../pmacs-p4-discovery \ -b discovery-stage1-commands \ githubsucks/discovery-stage1-commands ```