From 791bde1a0c9a207b5a297e0f1f7790116665fc16 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 17:42:03 -0400 Subject: [PATCH 01/12] =?UTF-8?q?docs(discovery):=20frame=20P4=20Stage=201?= =?UTF-8?q?=20=E2=80=94=20the=20describe/list=20command=20family?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `COHERENCE.md` §20 Priority 4, which §5 grades "substrate without surface — the sharpest instance of §1.1" and calls the best payoff-per-effort in the document. Nine describe/list commands over introspection that already exists, picking up the family root `M-x help` that #205 landed and documented for exactly this. The stage adds no Rust. `pmacs.describe.*`, `pmacs.keymap.list()`, `pmacs.command.list()` and `pmacs.config.list()` already return everything the commands render, and `parse_completion_source` accepts a Lua function as a `CompletionSource::Custom` — so `describe-setting`'s free-text prompt, which today turns a typo into a status-line error while `describe-command` has had completion all along, closes with no new Rust either. The consequential decision is a single rendering seam. `src/help.rs`'s cross-referenced 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`, and an acceptance pin replaces that seam with a counting stub — funnelling asserted, not merely intended. Records that `Command.predicate` is stored and exposed but evaluated nowhere, and deliberately does not start evaluating it: that makes commands stop being invocable, which needs its own decision about what "unavailable" means at each call site. A preservation pin registers a raising predicate and asserts the command still runs, so a later stage has to change that pin knowingly. Adds no keybindings at all — the help-prefix question is taken once, for the whole family, by the stage that can weigh it against the constraint #205 recorded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 38 +++ ...discovery-stage1-command-family-framing.md | 265 ++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 docs/discovery-stage1-command-family-framing.md diff --git a/docs/active-work.md b/docs/active-work.md index 147f568..1371b85 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -380,6 +380,44 @@ which would have re-conflicted on every merge. githubsucks/journey-stage1b3-welcome ``` +## Discovery Stage 1 (P4) — FRAMING OPEN, revision 1 + +- **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. +- **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()`, + `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. +- **Deliberately deferred, each with a reason:** richer M-x rows + (`MinibufferPrompt.candidates` is `Vec` — 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. +- Recovery: + + ```sh + git fetch githubsucks + git worktree add ../pmacs-p4-discovery \ + -b discovery-stage1-commands \ + githubsucks/discovery-stage1-commands + ``` + ## Generated-buffer immutability lane (Arc: workbench primitives) — STAGE 1 MERGED; STAGE 2 IS NEXT **Framing #188 (revision 7) and Stage 1 #191 are both on `main` @ diff --git a/docs/discovery-stage1-command-family-framing.md b/docs/discovery-stage1-command-family-framing.md new file mode 100644 index 0000000..a210965 --- /dev/null +++ b/docs/discovery-stage1-command-family-framing.md @@ -0,0 +1,265 @@ +# Discovery Stage 1 — the describe/list command family + +**Status: framing, rev 1 — awaiting approval.** +**Serves `COHERENCE.md` §5 (unify discoverability), §1.1 (substrate +without surface), §20 Priority 4.** + +## 0. Revision history + +- 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, description }` | +| `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:** describe-key, describe-mode, describe-hook, +describe-buffer, where-is, list-commands, list-keybindings, apropos. + +### 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 surfaced as +`has_predicate` (`mod.rs:6204`) and handed out whole (`:6257`). No 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). + +**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). + +**A family of eight new commands each calling `show_help_text` turns a +two-site migration into a ten-site one.** §3.4 is the answer to that, +and it is the most consequential decision in this framing. + +## 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 a completion source + +It gains a completion source so a typo cannot reach `on_accept`. The +dired trade (§2.3) applies but resolves differently here: dired's prompt +takes an arbitrary *path*, where a shadowing candidate silently opens +the wrong directory; this prompt takes a name **from a closed set**, so +a candidate is what the user wants and free text is the failure mode. + +**No Rust is needed for it.** `parse_completion_source` +(`src/lua_bindings/mod.rs:14145-14165`) accepts the strings `none` / +`commands` / `buffers` / `files` **and a Lua `Function`**, which becomes +`CompletionSource::Custom` and is called for candidates. So the source +is `function() return names_from(pmacs.config.list()) end` — the stage +stays entirely Lua, and `Custom` is the general escape hatch every other +command in §3.1 can use if it needs one. + +### 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 rendering seam, so the later unification is one site + +Every new command renders through **`pmacs.editor._show_help`** — the +seam #205 added — and **not** by calling `show_help_text` or building +its own buffer. + +That is the whole mitigation for §2.5: when the help-layer unification +stage arrives, migrating to `src/help.rs`'s richer renderer is a change +at **one** Lua function, not at ten call sites. A stage that adds +consumers to a duplicated layer without funnelling them is how the +duplication becomes permanent. + +**Corollary this stage must respect:** no new command may render +anything `src/help.rs` cannot eventually produce. Where the Lua renderer +is poorer (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. + +1. **N — each of the nine commands exists and renders content.** Driven + through `pmacs.command.invoke_interactive` (the M-x path), asserting + **content produced** in `*help*` — not that a buffer exists. +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, not a fixed expected list, with + a non-empty precondition so the loop cannot be vacuous. +4. **N — `apropos` matches on descriptions, not only names.** Search for + a word that appears in exactly one command's *description* and in no + command *name*, and assert that command is listed. This is the pin + that distinguishes apropos from a name filter. +5. **N — `describe-setting` refuses a typo before `on_accept`.** With + the completion source attached, assert `pmacs.minibuffer.selected()` + resolves to a real setting — the pre-RET observable, since + `accept()` does `session.take()` and nothing survives it. +6. **N — `M-x help` lists the family.** Every command in §3.1 appears in + the index. A property over the family list, so adding a tenth command + without indexing it fails. +7. **P — every new command renders through `_show_help`.** Replace that + seam with a counting stub and assert the count equals the number of + commands exercised. This is §3.4's guarantee, and without it the + funnelling is a convention rather than a fact. +8. **P — the existing describe/list commands still work.** + `editor.describe-command`, `editor.describe-setting`, + `editor.list-buffers`, `editor.list-workers` unchanged. Targeted + mutation: renaming rather than retaining them. +9. **P — no command's predicate is evaluated.** Register a command whose + predicate raises, then invoke it through M-x and assert it **runs**. + Pins §2.4's deliberate non-change, so a later stage that starts + evaluating predicates has to change this pin knowingly. + +## 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. + +## 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 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#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 + 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. + +```sh +git fetch githubsucks +git worktree add ../pmacs-p4-discovery \ + -b discovery-stage1-commands \ + githubsucks/discovery-stage1-commands +``` From 9e79b6b9a3e190dd4b5be4d660198bb5d75fb47c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 17:52:56 -0400 Subject: [PATCH 02/12] =?UTF-8?q?docs(discovery):=20framing=20revision=202?= =?UTF-8?q?=20=E2=80=94=20close=20review=20round=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blocking, two major. All four accepted and verified in the code before the doc changed. Completion does not close the free-text hole, and revision 1 said it did. `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; separately, a fuzzy near-miss can silently describe a DIFFERENT setting, which is a new failure mode revision 1 did not name. Completion is reframed as assistance, the acceptance pins both outcomes, and closed-set acceptance semantics are named as Rust work rather than smuggled in as a side effect of adding a source. `invoke_interactive` is not the M-x path — the exact error #205 corrected one PR earlier. It rotates the interactive-command boundary and calls the body; it opens no palette. The acceptance now states the real path once and drives it: dispatch M-x, type the name, assert the selected candidate BEFORE RET (accept does `session.take()`), accept, and — for the five commands that take an argument — drive the second prompt too. A pin that stops after the first RET has tested the palette, not the command. The `_show_help` seam 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, and the seam takes already-flattened text, so a later migration still has to change each command's subject-specific logic. The claim is narrowed to what is true — one owner for Lua `*help*` writes — and paired with a per-subject renderer function so the future Rust work is enumerated rather than discovered. Ground truth corrected: the missing-command list was eight and omitted `list-settings` while §3.1 said nine; the site count said ten where nine additions make eleven; `pmacs.keymap.lookup` does not return `description` (it passes `cmd = None`); and the predicate sites cited were `MenuItem` fields, not `Command.predicate`. The predicate conclusion survives on the correct evidence — `src/help.rs:76` and one assertion past `#[cfg(test)]`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 8 +- ...discovery-stage1-command-family-framing.md | 246 +++++++++++++----- 2 files changed, 193 insertions(+), 61 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 1371b85..69e2fcc 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -401,7 +401,13 @@ which would have re-conflicted on every merge. 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. + 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. - **Deliberately deferred, each with a reason:** richer M-x rows (`MinibufferPrompt.candidates` is `Vec` — protocol change); `Command` gaining title/category/flags (~147 definition sites); diff --git a/docs/discovery-stage1-command-family-framing.md b/docs/discovery-stage1-command-family-framing.md index a210965..b1384fb 100644 --- a/docs/discovery-stage1-command-family-framing.md +++ b/docs/discovery-stage1-command-family-framing.md @@ -1,11 +1,44 @@ # Discovery Stage 1 — the describe/list command family -**Status: framing, rev 1 — awaiting approval.** +**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** 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). @@ -40,7 +73,7 @@ stage adds no Rust. | `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, description }` | +| `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 @@ -57,8 +90,10 @@ what the surface leaves out.** `editor.describe-instance[-buffer]`, `editor.list-buffers`, `editor.list-workers`, and `help` (#205). -**Missing entirely:** describe-key, describe-mode, describe-hook, -describe-buffer, where-is, list-commands, list-keybindings, apropos. +**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 @@ -79,11 +114,17 @@ 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 surfaced as -`has_predicate` (`mod.rs:6204`) and handed out whole (`:6257`). No 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). +`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 @@ -96,9 +137,12 @@ decision about what "unavailable" means at each call site. is **orphaned** — the reachable renderer is the Lua `show_help_text`, which renders *less* (no source, no scope). -**A family of eight new commands each calling `show_help_text` turns a -two-site migration into a ten-site one.** §3.4 is the answer to that, -and it is the most consequential decision in this framing. +**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 @@ -125,21 +169,42 @@ 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 a completion source +### 3.2 `describe-setting` gains completion — which is assistance, not validation -It gains a completion source so a typo cannot reach `on_accept`. The -dired trade (§2.3) applies but resolves differently here: dired's prompt -takes an arbitrary *path*, where a shadowing candidate silently opens -the wrong directory; this prompt takes a name **from a closed set**, so -a candidate is what the user wants and free text is the failure mode. +Rev 1 claimed a completion source means "a typo cannot reach +`on_accept`". **It does not.** -**No Rust is needed for it.** `parse_completion_source` -(`src/lua_bindings/mod.rs:14145-14165`) accepts the strings `none` / -`commands` / `buffers` / `files` **and a Lua `Function`**, which becomes -`CompletionSource::Custom` and is called for candidates. So the source -is `function() return names_from(pmacs.config.list()) end` — the stage -stays entirely Lua, and `Custom` is the general escape hatch every other -command in §3.1 can use if it needs one. +```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.** `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 @@ -147,60 +212,116 @@ command in §3.1 can use if it needs one. above, so the arc's own promise — *`help` stays the index they are reached from* — is kept rather than merely restated. -### 3.4 One rendering seam, so the later unification is one site +### 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` or building -its own buffer. +seam #205 added — and **not** by calling `show_help_text` directly or +building its own buffer. -That is the whole mitigation for §2.5: when the help-layer unification -stage arrives, migrating to `src/help.rs`'s richer renderer is a change -at **one** Lua function, not at ten call sites. A stage that adds -consumers to a duplicated layer without funnelling them is how the -duplication becomes permanent. +**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. -**Corollary this stage must respect:** no new command may render -anything `src/help.rs` cannot eventually produce. Where the Lua renderer -is poorer (no source, no scope), the new commands render the poorer form -rather than inventing a third shape. +**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. -1. **N — each of the nine commands exists and renders content.** Driven - through `pmacs.command.invoke_interactive` (the M-x path), asserting - **content produced** in `*help*` — not that a buffer exists. +### 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. + +**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 + +1. **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*`. 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, not a fixed expected list, with - a non-empty precondition so the loop cannot be vacuous. -4. **N — `apropos` matches on descriptions, not only names.** Search for - a word that appears in exactly one command's *description* and in no - command *name*, and assert that command is listed. This is the pin - that distinguishes apropos from a name filter. -5. **N — `describe-setting` refuses a typo before `on_accept`.** With - the completion source attached, assert `pmacs.minibuffer.selected()` - resolves to a real setting — the pre-RET observable, since - `accept()` does `session.take()` and nothing survives it. -6. **N — `M-x help` lists the family.** Every command in §3.1 appears in - the index. A property over the family list, so adding a tenth command - without indexing it fails. -7. **P — every new command renders through `_show_help`.** Replace that - seam with a counting stub and assert the count equals the number of - commands exercised. This is §3.4's guarantee, and without it the - funnelling is a convention rather than a fact. + 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.** 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. +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 tenth command without indexing it fails. +7. **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. 8. **P — the existing describe/list commands still work.** `editor.describe-command`, `editor.describe-setting`, `editor.list-buffers`, `editor.list-workers` unchanged. Targeted mutation: renaming rather than retaining them. 9. **P — no command's predicate is evaluated.** Register a command whose - predicate raises, then invoke it through M-x and assert it **runs**. - Pins §2.4's deliberate non-change, so a later stage that starts - evaluating predicates has to change this pin knowingly. + 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 @@ -220,6 +341,11 @@ falsified by a named mutation. 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 From aa932f9586b846aa091a97564845dcfd6c5f3723 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 18:08:58 -0400 Subject: [PATCH 03/12] =?UTF-8?q?docs(discovery):=20framing=20revision=203?= =?UTF-8?q?=20=E2=80=94=20close=20review=20round=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 57 +++++---- ...discovery-stage1-command-family-framing.md | 110 +++++++++++++++--- 2 files changed, 128 insertions(+), 39 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 69e2fcc..298893f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -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` — 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 diff --git a/docs/discovery-stage1-command-family-framing.md b/docs/discovery-stage1-command-family-framing.md index b1384fb..cc794a5 100644 --- a/docs/discovery-stage1-command-family-framing.md +++ b/docs/discovery-stage1-command-family-framing.md @@ -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 From 1cc9d96ba6e515a9dd99b1fe5fe2e4aa5e4564e7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 18:12:59 -0400 Subject: [PATCH 04/12] =?UTF-8?q?docs(discovery):=20framing=20revision=204?= =?UTF-8?q?=20=E2=80=94=20close=20review=20round=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two factual corrections, both accepted and both verified in the code. The custom completion source does not control display order. Revision 3 justified sorting the pool by claiming `Custom` candidates appear in return order; `recompute_candidates` hands the pool straight to `filter_and_sort`, which ranks by fuzzy score descending and breaks ties lexically, so the source's order never reaches the user. The sort is kept, for a reason that is actually true: `filter_and_sort` applies `.take(CANDIDATE_LIMIT)` to the FILTERED iterator before sorting, so when more settings match than the limit, pool order decides which survive truncation. Registration order would make that vary with an unrelated config edit; sorting makes it reproducible. Read-only would not mitigate the foreign-`*help*` collision either. Revision 3 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 matches on the name and clears theirs. The missing guarantee is ownership identity: a private table of buffers this module created, so found-by-name is not adoption. `listview` carries it as `panels` and dired as its handle table; this mechanism carries neither. Naming the wrong missing guarantee would send a later fix at the wrong layer, which is why the correction is worth its own paragraph rather than a word swap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 23 +++++--- ...discovery-stage1-command-family-framing.md | 52 +++++++++++++++---- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 298893f..cea9819 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -380,13 +380,14 @@ which would have re-conflicted on every merge. githubsucks/journey-stage1b3-welcome ``` -## Discovery Stage 1 (P4) — FRAMING OPEN, revision 3 +## Discovery Stage 1 (P4) — FRAMING OPEN, revision 4 - **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 3, - two review rounds closed (round 1: two blocking, two major; round 2: - two blocking, two major; all accepted). + PR yet.** `docs/discovery-stage1-command-family-framing.md` revision 4, + three review rounds closed (round 1: two blocking, two major; round 2: + two blocking, two major; round 3: two factual corrections; 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, @@ -405,7 +406,11 @@ which would have re-conflicted on every merge. 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. + strings. **It does not control display order** — + `recompute_candidates` runs `filter_and_sort` (fuzzy score, lexical + tiebreak); sorting the pool matters only because `.take( + CANDIDATE_LIMIT)` runs *before* the sort, so pool order decides which + candidates survive truncation. - **`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** @@ -419,8 +424,12 @@ which would have re-conflicted on every merge. 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 + **`*help*` is ordinary editable content** — no read-only intercept, no + generated-content invariant. And read-only would **not** fix the + foreign-buffer hazard either: a user's own `*help*` carries no + intercept of ours, so the renderer still finds it by name and clears + it. The missing guarantee is **ownership identity**, which `listview` + and dired both carry and this mechanism does not. 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 diff --git a/docs/discovery-stage1-command-family-framing.md b/docs/discovery-stage1-command-family-framing.md index cc794a5..dd94905 100644 --- a/docs/discovery-stage1-command-family-framing.md +++ b/docs/discovery-stage1-command-family-framing.md @@ -1,11 +1,25 @@ # Discovery Stage 1 — the describe/list command family -**Status: framing, rev 3 — awaiting review round 3.** +**Status: framing, rev 4 — awaiting review round 4.** **Serves `COHERENCE.md` §5 (unify discoverability), §1.1 (substrate without surface), §20 Priority 4.** ## 0. Revision history +- 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.** @@ -256,9 +270,18 @@ source = function() 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. +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 @@ -280,17 +303,26 @@ shared policies get decided in one place instead of eleven: - **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. +- 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. 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. +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, From f4359f12b48535ab1dc5ae00ec6ade49f350ba07 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 18:20:41 -0400 Subject: [PATCH 05/12] =?UTF-8?q?docs(discovery):=20framing=20revision=205?= =?UTF-8?q?=20=E2=80=94=20decide=20Q#D2=20and=20Q#D3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No review findings at 1cc9d96; these are the two product choices the framing deliberately left open. Q#D2 resolves to `help.*` canonical with two forwarders. The family is eleven commands under one prefix, so typing `help` at M-x surfaces all of it — which is the discoverability win this arc exists for. `editor.describe-command` and `editor.describe-setting` stay registered as forwarders, so nothing in muscle memory or in `docs/keybindings.md` breaks. Two forwarders are duplication of exactly the kind §5 complains about; they are accepted as the bounded price of not breaking documented names, and they carry a deprecation path. What is not accepted is the split family revision 3 shipped. Q#D3 resolves to 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 and destroy the precision that makes apropos worth having. Acceptance 4 now pins the decision rather than the intent: a subsequence that is not a substring must find nothing. Counts and pins follow the decision: the family is eleven throughout, the `_show_help` counting stub expects eleven (the two renamed commands are in it precisely because they were the pre-existing direct callers), and the preservation pin now drives the forwarders through the real M-x path — dropping them after the rename is the failure a user with muscle memory would hit first. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 21 ++- ...discovery-stage1-command-family-framing.md | 145 +++++++++++------- 2 files changed, 101 insertions(+), 65 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index cea9819..04805bd 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -380,19 +380,26 @@ which would have re-conflicted on every merge. githubsucks/journey-stage1b3-welcome ``` -## Discovery Stage 1 (P4) — FRAMING OPEN, revision 4 +## Discovery Stage 1 (P4) — FRAMING APPROVED (rev 5); implementation next - **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 4, + PR yet.** `docs/discovery-stage1-command-family-framing.md` revision 5, three review rounds closed (round 1: two blocking, two major; round 2: two blocking, two major; round 3: two factual corrections; all - accepted). + accepted), and **Q#D2 / Q#D3 decided by the user**. - **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. + the best payoff-per-effort in this document". **Eleven commands under + one `help.*` prefix**: nine new (describe-key/mode/hook/buffer, + where-is, list-commands, list-keybindings, list-settings, apropos) + plus `editor.describe-command` / `editor.describe-setting` renamed, + with the old names retained as **forwarders** so nothing documented + breaks. Typing `help` at M-x surfaces the whole family, which is the + discoverability win the arc exists for (Q#D2). +- **`apropos` matches by SUBSTRING, not fuzzy** (Q#D3). `fuzzy_score` + is subsequence-based and descriptions are long sentences, so fuzzy + would match nearly every command. Pinned by a + subsequence-that-is-not-a-substring finding nothing. - **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 diff --git a/docs/discovery-stage1-command-family-framing.md b/docs/discovery-stage1-command-family-framing.md index dd94905..32349d1 100644 --- a/docs/discovery-stage1-command-family-framing.md +++ b/docs/discovery-stage1-command-family-framing.md @@ -1,11 +1,25 @@ # Discovery Stage 1 — the describe/list command family -**Status: framing, rev 4 — awaiting review round 4.** +**Status: framing, rev 5 — Q#D2 and Q#D3 decided; ready for implementation approval.** **Serves `COHERENCE.md` §5 (unify discoverability), §1.1 (substrate without surface), §20 Priority 4.** ## 0. Revision history +- 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 @@ -174,8 +188,9 @@ decision about what "unavailable" means at each call site. 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 +**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, @@ -183,40 +198,44 @@ lists, or apropos**. ## 3. Design -### 3.1 The family +### 3.1 The family — eleven commands under one prefix -Nine commands, all rendering existing data: +**Nine new**, all rendering data that already exists: | 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()` | +| `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) | -**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. +**Two renamed**, so the family is not split: -**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*. +| Command | Was | +|---|---| +| `help.describe-command` | `editor.describe-command` | +| `help.describe-setting` | `editor.describe-setting` | -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. +**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 @@ -386,18 +405,24 @@ the palette, not the command. ### 4.1 Pins -1. **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*`. +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.** 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. +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 + **subsequence that is not a substring** — letters present in order + but not contiguous — finds **nothing**. The second is what pins + Q#D3's decision rather than leaving matching semantics to whatever + the implementation reaches for. 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 @@ -408,15 +433,19 @@ the palette, not the command. `resolve_accepted_value` contradicts.* 6. **N — `M-x help` lists the family.** A property over the family list, so adding a tenth command without indexing it fails. -7. **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. -8. **P — the existing describe/list commands still work.** - `editor.describe-command`, `editor.describe-setting`, - `editor.list-buffers`, `editor.list-workers` unchanged. Targeted - mutation: renaming rather than retaining them. +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 @@ -464,20 +493,20 @@ the palette, not the command. - **Config registry: not adopted.** Nothing here is a tunable. - **Background-work attribution:** unchanged. -## 7. Questions +## 7. Questions — decided -- **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 - M-x. Recommended: substring, and say so in the command description. +- **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 From 513a7dfa58ffc87d7677a8c88f5da94764a9c27f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 18:28:35 -0400 Subject: [PATCH 06/12] docs(discovery): finalize Stage 1 framing review Correct the M-x prompt census: six of the eleven canonical commands need a second prompt, including describe-command. Make the apropos substring negative discriminate against fuzzy matching with a concrete non-contiguous fixture and a no-substring precondition. Update the help index mutation to the twelfth canonical command, and carry all three corrections into the active-work ledger. --- docs/active-work.md | 19 +++++--- ...discovery-stage1-command-family-framing.md | 44 ++++++++++++++----- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 04805bd..7628388 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -380,14 +380,15 @@ which would have re-conflicted on every merge. githubsucks/journey-stage1b3-welcome ``` -## Discovery Stage 1 (P4) — FRAMING APPROVED (rev 5); implementation next +## Discovery Stage 1 (P4) — FRAMING APPROVED (rev 6); implementation next - **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 5, + PR yet.** `docs/discovery-stage1-command-family-framing.md` revision 6, three review rounds closed (round 1: two blocking, two major; round 2: two blocking, two major; round 3: two factual corrections; all - accepted), and **Q#D2 / Q#D3 decided by the user**. + accepted), Q#D2 / Q#D3 decided by the user, and the final review's + acceptance corrections applied. - **What it is.** `COHERENCE.md` §20 Priority 4 — "almost pure wiring, the best payoff-per-effort in this document". **Eleven commands under one `help.*` prefix**: nine new (describe-key/mode/hook/buffer, @@ -399,7 +400,10 @@ which would have re-conflicted on every merge. - **`apropos` matches by SUBSTRING, not fuzzy** (Q#D3). `fuzzy_score` is subsequence-based and descriptions are long sentences, so fuzzy would match nearly every command. Pinned by a - subsequence-that-is-not-a-substring finding nothing. + `test.apropos-subsequence-fixture` whose `qzjx` letters occur as `q z + j x`, only after asserting no registered name or description contains + `qzjx` as a substring; it must find nothing, whereas fuzzy finds the + fixture. - **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 @@ -422,8 +426,11 @@ which would have re-conflicted on every merge. 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. + Six of the eleven canonical commands (`help.describe-command`, + `help.describe-setting`, `help.describe-key`, `help.describe-hook`, + `help.where-is`, and `help.apropos`) 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 diff --git a/docs/discovery-stage1-command-family-framing.md b/docs/discovery-stage1-command-family-framing.md index 32349d1..fc6bd73 100644 --- a/docs/discovery-stage1-command-family-framing.md +++ b/docs/discovery-stage1-command-family-framing.md @@ -1,11 +1,28 @@ # Discovery Stage 1 — the describe/list command family -**Status: framing, rev 5 — Q#D2 and Q#D3 decided; ready for implementation approval.** +**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.** @@ -397,11 +414,11 @@ 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. +**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 @@ -418,11 +435,14 @@ the palette, not the command. 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 - **subsequence that is not a substring** — letters present in order - but not contiguous — finds **nothing**. The second is what pins - Q#D3's decision rather than leaving matching semantics to whatever - the implementation reaches for. + (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 @@ -432,7 +452,7 @@ the palette, not the command. *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 tenth command without indexing it fails. + 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 From 44bd2201e7867c990458fd1ec8d33e834e0750dd Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 19:04:48 -0400 Subject: [PATCH 07/12] =?UTF-8?q?feat(help):=20the=20discovery=20command?= =?UTF-8?q?=20family=20=E2=80=94=20P4=20Stage=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `docs/discovery-stage1-command-family-framing.md` (approved at revision 6). `COHERENCE.md` §5 graded discoverability "substrate without surface": the registries already carried descriptions, source locations and reverse key lookup, and almost none of it was reachable. Eleven commands under one `help.*` prefix, so typing `help` at M-x surfaces the whole family. Nine are new; `editor.describe-command` and `editor.describe-setting` are renamed in, with the old names retained as forwarders so nothing documented breaks. No Rust. Every command renders data `pmacs.describe.*`, `pmacs.keymap.list()`, `pmacs.command.list()` and `pmacs.config.list()` already return, and `describe-setting`'s completion source is a Lua function via `CompletionSource::Custom`, which needed no binding work either — correcting a comment in `default.lua` that claimed `source` was a fixed Rust-side vocabulary. `apropos` matches by substring, not fuzzy: `fuzzy_score` is subsequence-based and descriptions are long sentences, so fuzzy would match nearly every command. Two disciplines the file keeps. Every command renders through the public `pmacs.editor._show_help`, which buys one owner for the shared `*help*` policy — reuse-by-name, wholesale replacement, `q`, and the foreign-buffer hazard. It does NOT buy a one-site migration to `src/help.rs`, which has no renderer for settings, lists or apropos; so rendering is a named per-subject function, and the future Rust work is enumerated per subject rather than discovered per call site. The seam-counting pin earned its place immediately: the two renamed commands were still calling the file-local `show_help_text`, so the funnel was fiction for exactly the two commands that predate it. They now call the public seam, with a comment saying why the local is not used from the same file. Moves `help` out of `welcome.lua` into the new `runtime/help.lua`, which owns the family and loads after it so the index can read `pmacs.welcome.entries`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- builtin/commands/default.lua | 49 +++- builtin/runtime/help.lua | 392 ++++++++++++++++++++++++++++ builtin/runtime/welcome.lua | 34 +-- src/editor.rs | 10 + tests/discovery_acceptance.rs | 463 ++++++++++++++++++++++++++++++++++ 5 files changed, 907 insertions(+), 41 deletions(-) create mode 100644 builtin/runtime/help.lua create mode 100644 tests/discovery_acceptance.rs diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 48d6384..bcf54df 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -1274,7 +1274,15 @@ function pmacs.editor._show_help(text) show_help_text(text) end -cmd { name = "editor.describe-command", +-- The two family commands below call `pmacs.editor._show_help`, NOT the +-- local `show_help_text`, even though they are in the same file and the +-- local is in scope. That is deliberate: discovery Stage 1's funnel +-- ("one owner for `*help*` writes") is only real if every command goes +-- through the PUBLIC seam — a command calling the local bypasses any +-- later change made at the seam, and bypassed the acceptance pin that +-- counts seam calls, which is how this was caught. + +cmd { name = "help.describe-command", description = "Prompt for a command name and render its description in *help*.", fn = function() pmacs.minibuffer.read { @@ -1303,7 +1311,7 @@ cmd { name = "editor.describe-command", lines[#lines + 1] = " " .. tostring(seq) end end - show_help_text(table.concat(lines, "\n")) + pmacs.editor._show_help(table.concat(lines, "\n")) end, } end } @@ -1315,12 +1323,17 @@ cmd { name = "editor.describe-command", -- way in, modeled on `editor.describe-command` directly above and sharing -- its `*help*` buffer handling. -- --- The prompt takes free text: `pmacs.minibuffer.read`'s `source` is a --- fixed vocabulary ("commands", "buffers") resolved in Rust, and adding a --- settings source means touching the minibuffer candidate machinery, --- which this arc deliberately stays out of. `pmacs.config.list()` is the --- programmatic way to enumerate names meanwhile; a completion source (and --- an M-x list-settings panel) are named deferrals in the framing. +-- The prompt now completes. That comment used to say `source` is "a fixed +-- vocabulary ("commands", "buffers") resolved in Rust" — it is not: +-- `parse_completion_source` also accepts a Lua **function**, which +-- becomes `CompletionSource::Custom` and is called for candidates. So a +-- settings source needs no Rust at all (discovery Stage 1). +-- +-- **Completion here 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 `no such setting` path below still earns its +-- keep. Refusing a non-candidate outright is Rust work and is deferred. local function describe_setting_lines(name, info) -- Header block mirrors help.rs's `format_hook_text`: aligned label @@ -1354,12 +1367,28 @@ local function describe_setting_lines(name, info) return lines end -cmd { name = "editor.describe-setting", +cmd { name = "help.describe-setting", description = "Prompt for a setting name and render its definition in *help*.", fn = function() pmacs.minibuffer.read { prompt = "Describe setting: ", history = "command", + -- Sorted for DETERMINISTIC POOL CONSTRUCTION, not display + -- order: `recompute_candidates` runs `filter_and_sort`, which + -- ranks by fuzzy score and tie-breaks lexically, so this order + -- never reaches the user. It matters because + -- `.take(CANDIDATE_LIMIT)` is applied to the filtered iterator + -- BEFORE that sort, so pool order decides which candidates + -- survive truncation; registration order would make that vary + -- with an unrelated config edit. + source = function() + local names = {} + for _, d in ipairs(pmacs.config.list()) do + names[#names + 1] = d.name + end + table.sort(names) + return names + end, on_accept = function(name) if name == nil or name == "" then return end -- An undefined name raises NotFound rather than returning nil @@ -1370,7 +1399,7 @@ cmd { name = "editor.describe-setting", pmacs.editor.set_status("describe-setting: no such setting: " .. name) return end - show_help_text(table.concat(describe_setting_lines(name, info), "\n")) + pmacs.editor._show_help(table.concat(describe_setting_lines(name, info), "\n")) end, } end } diff --git a/builtin/runtime/help.lua b/builtin/runtime/help.lua new file mode 100644 index 0000000..5ac5096 --- /dev/null +++ b/builtin/runtime/help.lua @@ -0,0 +1,392 @@ +-- help.lua --- the discovery command family (P4 Stage 1). +-- Framing: docs/discovery-stage1-command-family-framing.md. +-- +-- `COHERENCE.md` §5 grades discoverability "substrate without surface — +-- the sharpest instance of §1.1": the registries already carry +-- descriptions, source locations and reverse key lookup, and almost none +-- of it was reachable. This file is the surface. It adds NO Rust: every +-- command below renders data `pmacs.describe.*`, `pmacs.keymap.list()`, +-- `pmacs.command.list()` and `pmacs.config.list()` already return. +-- +-- ORDERING CONTRACT: loads after `commands/default.lua` (for +-- `pmacs.editor._show_help` and the two renamed commands it forwards to) +-- and after `runtime/welcome.lua` (whose `pmacs.welcome.entries` the +-- index reads). +-- +-- TWO DISCIPLINES THIS FILE KEEPS +-- +-- 1. **One owner for `*help*` writes.** Every command renders through +-- `pmacs.editor._show_help` and never touches a buffer itself. That +-- does NOT make a later migration to `src/help.rs` a one-site change +-- — that layer has renderers for command/key/buffer/mode/hook/view +-- and none for settings, lists or apropos, and `_show_help` takes +-- already-flattened text. What the funnel buys is the shared policy +-- in one place: reuse-by-name, wholesale replacement, the `q` +-- binding, and the foreign-`*help*` hazard (found-by-name is not +-- ownership — a user's own `*help*` is adopted and cleared; the +-- missing guarantee is ownership identity, which `listview` has as +-- `panels` and dired as its handle table, and this does not). +-- +-- 2. **Rendering is a named per-subject function**, and the command body +-- does nothing but call it and hand the result to `_show_help`. That +-- keeps the semantics addressable, so the future help-unification +-- stage is enumerated per subject — replace the four `src/help.rs` +-- already covers, write three new Rust renderers for settings, lists +-- and apropos — rather than discovered per call site. + +pmacs.help = pmacs.help or {} + +local function show(text) + pmacs.editor._show_help(text) +end + +-- Sorted command names. `pmacs.command.list()` returns registration +-- order, which is not meaningful to a reader. +local function sorted_command_names() + local names = {} + for _, n in ipairs(pmacs.command.list()) do names[#names + 1] = n end + table.sort(names) + return names +end + +local function description_of(name) + local ok, info = pcall(pmacs.describe.command, name) + if ok and type(info) == "table" and type(info.description) == "string" then + return info.description + end + return "(no description)" +end + +-- --------------------------------------------------------------------- +-- Per-subject renderers (discipline 2) +-- --------------------------------------------------------------------- + +function pmacs.help.render_key(seq, info) + if type(info) ~= "table" then + return string.format("Key: %s\n\n (unbound in this buffer)\n", seq) + end + local lines = { + "Key: " .. seq, + "", + " Command: " .. tostring(info.command), + " Scope: " .. tostring(info.scope), + } + if info.source then lines[#lines + 1] = " Source: " .. tostring(info.source) end + lines[#lines + 1] = "" + lines[#lines + 1] = description_of(info.command) + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_mode(info) + if type(info) ~= "table" then return "Mode: (none)\n" end + local lines = { "Mode: " .. tostring(info.name or "(none)"), "" } + for k, v in pairs(info) do + if k ~= "name" then + lines[#lines + 1] = string.format(" %-12s %s", k .. ":", tostring(v)) + end + end + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_buffer(info) + if type(info) ~= "table" then return "Buffer: (none)\n" end + local lines = { "Buffer: " .. tostring(info.name), "" } + for _, k in ipairs({ "path", "major_mode", "modified", "read_only", "length" }) do + if info[k] ~= nil then + lines[#lines + 1] = string.format(" %-12s %s", k .. ":", tostring(info[k])) + end + end + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_hook(name, info) + local lines = { "Hook: " .. name, "" } + if type(info) ~= "table" then + lines[#lines + 1] = " (no listeners)" + return table.concat(lines, "\n") .. "\n" + end + lines[#lines + 1] = string.format(" %-12s %s", "kind:", tostring(info.kind)) + local listeners = info.listeners + if type(listeners) == "table" then + lines[#lines + 1] = string.format(" %-12s %d", "listeners:", #listeners) + for _, l in ipairs(listeners) do + local src = (type(l) == "table" and l.source) or l + lines[#lines + 1] = " " .. tostring(src) + end + end + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_where_is(name, bindings) + local lines = { "Where is: " .. name, "" } + if type(bindings) ~= "table" or #bindings == 0 then + lines[#lines + 1] = " (not bound to any key)" + lines[#lines + 1] = "" + lines[#lines + 1] = " Run it with M-x " .. name + return table.concat(lines, "\n") .. "\n" + end + for _, b in ipairs(bindings) do + local seq = (type(b) == "table" and b.sequence) or tostring(b) + local scope = (type(b) == "table" and b.scope) and (" (" .. tostring(b.scope) .. ")") or "" + lines[#lines + 1] = " " .. tostring(seq) .. scope + end + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_command_list(names) + local lines = { string.format("Commands (%d)", #names), "" } + for _, n in ipairs(names) do + lines[#lines + 1] = string.format(" %-34s %s", n, description_of(n)) + end + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_keybinding_list(rows) + -- Grouped by scope so buffer-local bindings are not mixed in with the + -- global map; sorted within a group by sequence. + local by_scope = {} + local scopes = {} + for _, r in ipairs(rows) do + local scope = tostring(r.scope) + if not by_scope[scope] then + by_scope[scope] = {} + scopes[#scopes + 1] = scope + end + table.insert(by_scope[scope], r) + end + table.sort(scopes) + local lines = { string.format("Key bindings (%d)", #rows), "" } + for _, scope in ipairs(scopes) do + local group = by_scope[scope] + table.sort(group, function(a, b) return tostring(a.sequence) < tostring(b.sequence) end) + lines[#lines + 1] = scope .. ":" + for _, r in ipairs(group) do + lines[#lines + 1] = string.format(" %-18s %s", tostring(r.sequence), tostring(r.command)) + end + lines[#lines + 1] = "" + end + return table.concat(lines, "\n") +end + +function pmacs.help.render_settings_list(rows) + local lines = { string.format("Settings (%d)", #rows), "" } + for _, d in ipairs(rows) do + lines[#lines + 1] = string.format(" %-34s %s", tostring(d.name), + tostring(d.description or "(no description)")) + end + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_apropos(needle, hits) + local lines = { string.format("Apropos %q (%d)", needle, #hits), "" } + if #hits == 0 then + lines[#lines + 1] = " (nothing matched)" + return table.concat(lines, "\n") .. "\n" + end + for _, h in ipairs(hits) do + lines[#lines + 1] = string.format(" %-34s %s", h.name, h.description) + end + return table.concat(lines, "\n") .. "\n" +end + +--- Commands whose name or description CONTAINS `needle`, case-insensitively. +--- +--- **Substring, deliberately, not fuzzy** (framing Q#D3). `fuzzy_score` +--- is subsequence-based and descriptions are long sentences, so a short +--- query's letters almost always appear in order — fuzzy here would match +--- nearly every command and destroy the precision that makes apropos +--- worth having. +function pmacs.help.apropos_hits(needle) + local lowered = tostring(needle):lower() + local hits = {} + if lowered == "" then return hits end + for _, name in ipairs(sorted_command_names()) do + local desc = description_of(name) + if name:lower():find(lowered, 1, true) or desc:lower():find(lowered, 1, true) then + hits[#hits + 1] = { name = name, description = desc } + end + end + return hits +end + +-- --------------------------------------------------------------------- +-- The index +-- --------------------------------------------------------------------- + +--- Every command in the family, in the order the index lists them. +--- Public so the acceptance suite can assert the index is complete as a +--- PROPERTY — adding a twelfth canonical command without indexing it +--- must fail, not silently pass. +pmacs.help.family = { + "help.describe-command", + "help.describe-setting", + "help.describe-key", + "help.describe-mode", + "help.describe-buffer", + "help.describe-hook", + "help.where-is", + "help.list-commands", + "help.list-keybindings", + "help.list-settings", + "help.apropos", +} + +local function index_text() + local lines = { "pmacs help", "" } + if type(pmacs.welcome) == "table" and type(pmacs.welcome.entries) == "table" then + lines[#lines + 1] = "Keys" + lines[#lines + 1] = "" + lines[#lines + 1] = string.format(" %-18s %s", "M-x", "run a command by name") + for _, e in ipairs(pmacs.welcome.entries) do + lines[#lines + 1] = string.format(" %-18s %s", e.keys, e.label) + end + lines[#lines + 1] = "" + end + lines[#lines + 1] = "Discovery commands" + lines[#lines + 1] = "" + for _, name in ipairs(pmacs.help.family) do + lines[#lines + 1] = string.format(" %-26s %s", name, description_of(name)) + end + lines[#lines + 1] = "" + lines[#lines + 1] = "The full keymap reference is docs/keybindings.md." + return table.concat(lines, "\n") .. "\n" +end + +pmacs.command.define { + name = "help", + description = "Index of the pmacs help and discovery commands.", + fn = function() show(index_text()) end, +} + +-- --------------------------------------------------------------------- +-- The family +-- --------------------------------------------------------------------- + +pmacs.command.define { + name = "help.describe-key", + description = "Describe what a key sequence is bound to in this buffer.", + fn = function() + pmacs.minibuffer.read { + prompt = "Describe key: ", + history = "command", + on_accept = function(seq) + if seq == nil or seq == "" then return end + local ok, info = pcall(pmacs.describe.key, seq) + show(pmacs.help.render_key(seq, ok and info or nil)) + end, + } + end, +} + +pmacs.command.define { + name = "help.describe-mode", + description = "Describe the active buffer's major mode.", + fn = function() + local buf = pmacs.window.buffer() + local ok, info = pcall(pmacs.describe.mode, buf) + show(pmacs.help.render_mode(ok and info or nil)) + end, +} + +pmacs.command.define { + name = "help.describe-buffer", + description = "Describe the active buffer.", + fn = function() + local buf = pmacs.window.buffer() + local ok, info = pcall(pmacs.describe.buffer, buf) + show(pmacs.help.render_buffer(ok and info or nil)) + end, +} + +pmacs.command.define { + name = "help.describe-hook", + description = "Describe a hook and list its listeners.", + fn = function() + pmacs.minibuffer.read { + prompt = "Describe hook: ", + history = "command", + on_accept = function(name) + if name == nil or name == "" then return end + local ok, info = pcall(pmacs.describe.hook, name) + show(pmacs.help.render_hook(name, ok and info or nil)) + end, + } + end, +} + +pmacs.command.define { + name = "help.where-is", + description = "Show which keys run a command.", + fn = function() + pmacs.minibuffer.read { + prompt = "Where is command: ", + source = "commands", + history = "command", + on_accept = function(name) + if name == nil or name == "" then return end + local ok, info = pcall(pmacs.describe.command, name) + if not ok or type(info) ~= "table" then + pmacs.editor.set_status("where-is: no such command: " .. name) + return + end + show(pmacs.help.render_where_is(name, info.key_bindings)) + end, + } + end, +} + +pmacs.command.define { + name = "help.list-commands", + description = "List every registered command with its description.", + fn = function() show(pmacs.help.render_command_list(sorted_command_names())) end, +} + +pmacs.command.define { + name = "help.list-keybindings", + description = "List every key binding, grouped by scope.", + fn = function() show(pmacs.help.render_keybinding_list(pmacs.keymap.list())) end, +} + +pmacs.command.define { + name = "help.list-settings", + description = "List every registered setting with its description.", + fn = function() show(pmacs.help.render_settings_list(pmacs.config.list())) end, +} + +pmacs.command.define { + name = "help.apropos", + description = "Search command names and descriptions by substring.", + fn = function() + pmacs.minibuffer.read { + prompt = "Apropos (substring): ", + history = "command", + on_accept = function(needle) + if needle == nil or needle == "" then return end + show(pmacs.help.render_apropos(needle, pmacs.help.apropos_hits(needle))) + end, + } + end, +} + +-- --------------------------------------------------------------------- +-- Forwarders (framing Q#D2) +-- --------------------------------------------------------------------- +-- +-- `help.*` is canonical, so typing `help` at M-x surfaces the whole +-- family. These two keep the documented names working for users whose +-- muscle memory and whose `docs/keybindings.md` predate the rename. +-- +-- Two names for one thing is the duplication §5 complains about; it is +-- the bounded price of not breaking documented commands, and it carries +-- a deprecation path a later stage can take. + +local function forward(old_name, new_name) + pmacs.command.define { + name = old_name, + description = string.format("Deprecated alias for `%s`.", new_name), + fn = function() pmacs.command.invoke_interactive(new_name) end, + } +end + +forward("editor.describe-command", "help.describe-command") +forward("editor.describe-setting", "help.describe-setting") diff --git a/builtin/runtime/welcome.lua b/builtin/runtime/welcome.lua index fa55b3c..e2468cf 100644 --- a/builtin/runtime/welcome.lua +++ b/builtin/runtime/welcome.lua @@ -69,34 +69,6 @@ end -- M-x help -- --------------------------------------------------------------------- -- --- The smallest version of §18's second item, included because the --- welcome would otherwise point at nothing. It is the ROOT of the --- eventual family: when the discovery arc adds `help.keys` and friends, --- `help` stays the index they are reached from, so no rename is owed. --- --- Renders through `editor.describe-command`'s existing `*help*` --- mechanism rather than growing a second help surface. -local function help_text() - local lines = { - "pmacs help", - "", - " M-x run a command by name", - } - for _, e in ipairs(pmacs.welcome.entries) do - lines[#lines + 1] = string.format(" %-18s %s", e.keys, e.label) - end - lines[#lines + 1] = "" - lines[#lines + 1] = " M-x editor.describe-command what a command does" - lines[#lines + 1] = " M-x editor.list-buffers every open buffer" - lines[#lines + 1] = "" - lines[#lines + 1] = "The full keymap reference is docs/keybindings.md." - return table.concat(lines, "\n") .. "\n" -end - -pmacs.command.define { - name = "help", - description = "Show the pmacs key and command cheat sheet.", - fn = function() - pmacs.editor._show_help(help_text()) - end, -} +-- The `help` command itself lives in `runtime/help.lua`, which owns the +-- whole discovery family and loads after this file so its index can read +-- `pmacs.welcome.entries` above. This file keeps only the greeting. diff --git a/src/editor.rs b/src/editor.rs index b089973..433ce80 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -716,6 +716,16 @@ impl EditorState { include_str!("../builtin/runtime/welcome.lua"), ) .expect("load welcome builtin chunk"); + // Discovery Stage 1: the help/describe/list family. After + // `welcome.lua` so its index can read `pmacs.welcome.entries`, + // and after `commands/default.lua` (run by `attach_editor`) for + // `pmacs.editor._show_help` and the two commands it forwards to. + lua_host + .eval( + Some("@pmacs/builtin/runtime/help.lua"), + include_str!("../builtin/runtime/help.lua"), + ) + .expect("load help builtin chunk"); // T M7.11 bundled-package bootstrap. Through M7.10 the REPL // was loaded directly via `eval(include_str!(...))`; the // M7.11 deliverable migrates it to the package system so it diff --git a/tests/discovery_acceptance.rs b/tests/discovery_acceptance.rs new file mode 100644 index 0000000..7b4d021 --- /dev/null +++ b/tests/discovery_acceptance.rs @@ -0,0 +1,463 @@ +// tests/discovery_acceptance.rs --- P4 Stage 1, the discovery family. + +//! `COHERENCE.md` §5 graded discoverability "substrate without surface". +//! These pins cover the surface: +//! `docs/discovery-stage1-command-family-framing.md` §4. +//! +//! **Every command is driven through the real M-x path**, stated once in +//! `run_from_palette` below. `pmacs.command.invoke_interactive` is *not* +//! M-x — it rotates the interactive-command boundary and calls the body; +//! it opens no palette. Journey Stage 1b-2 established this and Stage +//! 1b-3 re-established it, so it is encoded in a helper here rather than +//! left to each pin to remember. +//! +//! Six of the eleven canonical commands open a **second** prompt. A pin +//! that stops after the first RET has tested the palette, not the +//! command. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_owned()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_owned()).eval().unwrap() +} + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } +} + +fn minibuffer_active(s: &EditorState) -> bool { + eval(s, "return pmacs.minibuffer.is_active()") +} + +fn named_text(s: &EditorState, name: &str) -> String { + eval( + s, + &format!( + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == {name:?} then + return id:slice(0, id:len()) + end + end + return "" + "# + ), + ) +} + +fn help_text(s: &EditorState) -> String { + named_text(s, "*help*") +} + +/// Drive the **real** M-x path: dispatch `M-x`, type the command name, +/// assert the palette selected exactly that command *before* RET — the +/// only moment it is observable, since `accept()` does `session.take()` +/// and a selected candidate shadows typed text — then accept. +/// +/// `second` is the argument for the six commands that open another +/// prompt; `None` for the five that do not. +fn run_from_palette(s: &mut EditorState, command: &str, second: Option<&str>) { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('x'), KeyModifiers::ALT), + ); + assert!(minibuffer_active(s), "M-x must open the palette"); + type_str(s, command); + assert_eq!( + eval::>(s, "return pmacs.minibuffer.selected()").as_deref(), + Some(command), + "the palette must have {command} selected; a different candidate \ + would run a different command" + ); + press(s, KeyCode::Enter); + + if let Some(arg) = second { + assert!( + minibuffer_active(s), + "{command} takes an argument and must open a second prompt" + ); + exec(s, &format!("pmacs.minibuffer.set_contents({arg:?})")); + press(s, KeyCode::Enter); + } else { + assert!( + !minibuffer_active(s), + "{command} takes no argument; a second prompt means the census is wrong" + ); + } +} + +fn editor() -> EditorState { + EditorState::new() +} + +/// The eleven canonical commands and the argument each needs, if any. +/// **Six take one** — `describe-command` is easy to forget, because it +/// joined the family by rename rather than by being new. +fn family() -> Vec<(&'static str, Option<&'static str>)> { + vec![ + ("help.describe-command", Some("help.list-commands")), + ("help.describe-setting", None), // supplied per-test: needs a real setting + ("help.describe-key", Some("C-x C-f")), + ("help.describe-mode", None), + ("help.describe-buffer", None), + ("help.describe-hook", Some("buffer.after-load")), + ("help.where-is", Some("help.list-commands")), + ("help.list-commands", None), + ("help.list-keybindings", None), + ("help.list-settings", None), + ("help.apropos", Some("compile")), + ] +} + +// --------------------------------------------------------------------------- +// The family runs +// --------------------------------------------------------------------------- + +/// **N (acceptance 1)** — every canonical command runs from M-x and +/// renders content, including the second prompt where it takes one. +#[test] +fn d1_every_command_runs_from_the_palette_and_renders() { + for (name, arg) in family() { + let mut s = editor(); + // `describe-setting` needs a setting that exists; take the first. + let arg = if name == "help.describe-setting" { + Some(eval::(&s, "return pmacs.config.list()[1].name")) + } else { + arg.map(str::to_owned) + }; + run_from_palette(&mut s, name, arg.as_deref()); + let text = help_text(&s); + assert!( + !text.is_empty(), + "{name} must render content into *help*; got empty" + ); + } +} + +/// **N (acceptance 2)** — `where-is` agrees with the keymap. +/// +/// Falsified by rendering a static string. +#[test] +fn d2_where_is_reports_the_real_binding() { + let mut s = editor(); + exec( + &s, + "pmacs.command.define { name = 'test.whereis-probe', + description = 'probe', fn = function() end } + pmacs.keymap.bind { scope = 'global', sequence = 'C-c Q', + command = 'test.whereis-probe' }", + ); + run_from_palette(&mut s, "help.where-is", Some("test.whereis-probe")); + let text = help_text(&s); + assert!( + text.contains("C-c Q"), + "where-is must report the chord actually bound; got:\n{text}" + ); +} + +/// **N (acceptance 3)** — `list-keybindings` covers every binding +/// `keymap.list()` reports. A property over the data, not a fixed list. +#[test] +fn d3_list_keybindings_covers_every_binding() { + let mut s = editor(); + let sequences: Vec = eval( + &s, + "local out = {} + for _, r in ipairs(pmacs.keymap.list()) do out[#out+1] = r.sequence end + return out", + ); + assert!( + !sequences.is_empty(), + "precondition: the keymap must be non-empty or this loop is vacuous" + ); + run_from_palette(&mut s, "help.list-keybindings", None); + let text = help_text(&s); + for seq in sequences { + assert!( + text.contains(&seq), + "list-keybindings omits {seq:?}; got:\n{text}" + ); + } +} + +// --------------------------------------------------------------------------- +// apropos — substring, not fuzzy +// --------------------------------------------------------------------------- + +/// **N (acceptance 4)** — apropos matches descriptions, not only names, +/// **and does so by substring**. +/// +/// The negative half is the one that pins Q#D3. A bare "a subsequence +/// finds nothing" assertion would pass as an ordinary no-match; this +/// registers a fixture whose description contains `qzjx` **only** as the +/// non-contiguous sequence `q z j x`, and first proves no registered +/// command contains `qzjx` as a substring. A fuzzy implementation finds +/// the fixture, so the pin fails under fuzzy rather than passing. +#[test] +fn d4_apropos_matches_descriptions_by_substring_not_subsequence() { + let mut s = editor(); + exec( + &s, + "pmacs.command.define { name = 'test.apropos-description-probe', + description = 'zzyzx marker for the description-search pin', + fn = function() end } + pmacs.command.define { name = 'test.apropos-subsequence-fixture', + description = 'q z j x letters spaced apart on purpose', + fn = function() end }", + ); + + // Positive: a word in exactly one DESCRIPTION and no NAME. + let name_hits: i64 = eval( + &s, + "local n = 0 + for _, c in ipairs(pmacs.command.list()) do + if c:lower():find('zzyzx', 1, true) then n = n + 1 end + end + return n", + ); + assert_eq!(name_hits, 0, "precondition: 'zzyzx' is in no command NAME"); + run_from_palette(&mut s, "help.apropos", Some("zzyzx")); + assert!( + help_text(&s).contains("test.apropos-description-probe"), + "apropos must search descriptions, not only names; got:\n{}", + help_text(&s) + ); + + // Negative, discriminating: `qzjx` is a subsequence of the fixture's + // description but a substring of nothing. + let substring_hits: i64 = eval( + &s, + "local n = 0 + for _, c in ipairs(pmacs.command.list()) do + local d = pmacs.describe.command(c) + local desc = (d and d.description) or '' + if c:lower():find('qzjx', 1, true) or desc:lower():find('qzjx', 1, true) then + n = n + 1 + end + end + return n", + ); + assert_eq!( + substring_hits, 0, + "precondition: 'qzjx' must be a substring of nothing, or the \ + negative below proves nothing" + ); + + let mut s2 = editor(); + exec( + &s2, + "pmacs.command.define { name = 'test.apropos-subsequence-fixture', + description = 'q z j x letters spaced apart on purpose', + fn = function() end }", + ); + run_from_palette(&mut s2, "help.apropos", Some("qzjx")); + assert!( + !help_text(&s2).contains("test.apropos-subsequence-fixture"), + "substring matching must NOT find a subsequence — a fuzzy \ + implementation finds the fixture here; got:\n{}", + help_text(&s2) + ); +} + +// --------------------------------------------------------------------------- +// describe-setting completion +// --------------------------------------------------------------------------- + +/// **N (acceptance 5)** — completion assists, and a non-matching typo +/// still reaches the existing error path. +/// +/// Both halves, because §3.2 has two outcomes and rev 1 asserted only a +/// third that does not exist ("a typo cannot reach `on_accept`"). +#[test] +fn d5_describe_setting_completes_and_a_typo_still_errors() { + let mut s = editor(); + let first: String = eval(&s, "return pmacs.config.list()[1].name"); + + // (a) typing a real setting's full name selects it. + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('x'), KeyModifiers::ALT), + ); + type_str(&mut s, "help.describe-setting"); + press(&mut s, KeyCode::Enter); + assert!(minibuffer_active(&s), "the setting prompt must open"); + exec(&s, &format!("pmacs.minibuffer.set_contents({first:?})")); + assert_eq!( + eval::>(&s, "return pmacs.minibuffer.selected()").as_deref(), + Some(first.as_str()), + "a real setting name must be the selected candidate" + ); + press(&mut s, KeyCode::Enter); + assert!( + help_text(&s).contains(&first), + "accepting a completed setting describes it" + ); + + // (b) a name matching nothing still reaches `on_accept` and errors — + // completion is assistance, not validation. + let mut s2 = editor(); + run_from_palette( + &mut s2, + "help.describe-setting", + Some("qqzz-no-such-setting"), + ); + assert!( + s2.core.borrow().status.contains("no such setting"), + "a non-matching typo reaches the existing error path; status: {:?}", + s2.core.borrow().status + ); +} + +// --------------------------------------------------------------------------- +// The index +// --------------------------------------------------------------------------- + +/// **N (acceptance 6)** — `M-x help` lists the family, as a property. +/// +/// Targeted mutation: adding a twelfth canonical command without +/// indexing it. +#[test] +fn d6_the_help_index_lists_every_family_command() { + let mut s = editor(); + let family: Vec = eval(&s, "return pmacs.help.family"); + assert_eq!( + family.len(), + 11, + "the canonical family is eleven commands; update the index and \ + this pin together" + ); + run_from_palette(&mut s, "help", None); + let text = help_text(&s); + for name in family { + assert!(text.contains(&name), "the index omits {name}; got:\n{text}"); + } +} + +// --------------------------------------------------------------------------- +// Preservation +// --------------------------------------------------------------------------- + +/// **P (acceptance 7)** — every command's `*help*` write goes through +/// `_show_help`. +/// +/// Pins §3.4's actual claim: one owner for `*help*` writes. Not a +/// one-site migration claim — `src/help.rs` has no renderer for +/// settings, lists or apropos. +#[test] +fn d7_preservation_every_render_goes_through_the_one_seam() { + let mut s = editor(); + exec( + &s, + "_seam_calls = 0 + local real = pmacs.editor._show_help + pmacs.editor._show_help = function(text) + _seam_calls = _seam_calls + 1 + return real(text) + end", + ); + for (name, arg) in family() { + let arg = if name == "help.describe-setting" { + Some(eval::(&s, "return pmacs.config.list()[1].name")) + } else { + arg.map(str::to_owned) + }; + run_from_palette(&mut s, name, arg.as_deref()); + } + assert_eq!( + eval::(&s, "return _seam_calls"), + 11, + "all eleven commands must render through _show_help; a command \ + writing its own buffer would not be counted" + ); +} + +/// **P (acceptance 8)** — the old names still work, as forwarders. +/// +/// Targeted mutation: dropping the forwarders after the rename, which is +/// the failure a user with muscle memory hits first. +#[test] +fn d8_preservation_the_old_names_forward() { + let mut s = editor(); + run_from_palette( + &mut s, + "editor.describe-command", + Some("help.list-commands"), + ); + let forwarded = help_text(&s); + assert!( + forwarded.contains("help.list-commands"), + "editor.describe-command must still describe the command it is \ + given; got:\n{forwarded}" + ); + + let mut s2 = editor(); + run_from_palette(&mut s2, "help.describe-command", Some("help.list-commands")); + assert_eq!( + forwarded, + help_text(&s2), + "the forwarder must render the same subject as its target" + ); +} + +/// **P (acceptance 8, cont.)** — the untouched list commands still work. +#[test] +fn d8b_preservation_list_buffers_and_workers_are_untouched() { + let s = editor(); + for name in ["editor.list-buffers", "editor.list-workers"] { + assert!( + eval::(&s, &format!("return pmacs.command.exists({name:?})")), + "{name} must still be registered" + ); + } +} + +/// **P (acceptance 9)** — no command's predicate is evaluated. +/// +/// Driven through the real palette, not `invoke_interactive` directly: +/// otherwise it would pass even if M-x itself grew predicate filtering. +/// A stage that starts evaluating predicates must change this pin +/// knowingly. +#[test] +fn d9_preservation_a_raising_predicate_does_not_block_a_command() { + let mut s = editor(); + exec( + &s, + "_ran = false + pmacs.command.define { + name = 'test.predicate-probe', + description = 'probe whose predicate raises', + predicate = function() error('predicate evaluated') end, + fn = function() _ran = true end, + }", + ); + run_from_palette(&mut s, "test.predicate-probe", None); + assert!( + eval::(&s, "return _ran"), + "the command must run: predicates are stored and exposed but \ + never evaluated (framing §2.4)" + ); +} From c0407dc6a7133023d3183a7baac0142bef35f2b1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 19:17:22 -0400 Subject: [PATCH 08/12] =?UTF-8?q?docs(coherence):=20=C2=A75=20substrate-wi?= =?UTF-8?q?thout-surface=20->=20Partial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per §25 the audited claim this stage falsifies is updated on the landing PR. §5's scorecard row and ground truth both said the registration metadata was best-in-class with almost no way for a user to reach it; eleven `help.*` commands now reach it. The row names what is still missing rather than implying the concern is closed: packages and workers have no discovery surface, `Command` still has no title/category/aliases/flags, M-x rows are still bare name strings, the predicate is still never evaluated, settings value provenance is still absent, and there is still no help prefix key. The original audit grade is kept inline for reference. `docs/keybindings.md` gains the family table and records that the commands are reachable by name and bound to nothing — the help-prefix decision belongs to a later stage, for the reason #205 recorded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- COHERENCE.md | 22 ++++++++++++++++++++-- docs/active-work.md | 24 +++++++++++++++++++++--- docs/agent-handoff.md | 31 +++++++++++++++++++++++++++++++ docs/keybindings.md | 22 ++++++++++++++++++++-- 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 670b235..15a05d9 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -98,7 +98,7 @@ remain open to them. | 2 | Golden product journey | **Runs to step 10** | `pmacs .` opens the directory (1a); the interface introduces itself (1b-3); a missing language server says so (1b-2, #204); a build is bound and prefilled (1b-1, #203). Steps 1, 11 and 12 remain the thin end | | 3 | Zero-configuration state | **Partial** | Defaults genuinely strong; missing-tool failure is silent, not graceful | | 4 | Progressive disclosure | **Inverted** | The advanced level is real; the beginner level is the missing one | -| 5 | Unified discoverability | **Substrate without surface** | Best-in-class registration metadata; almost no way for a user to reach it | +| 5 | Unified discoverability | **Partial** | Discovery Stage 1: eleven `help.*` commands (describe key/mode/hook/buffer/command/setting, where-is, list commands/keybindings/settings, apropos) over the existing registries, indexed by `M-x help`. Commands, keys, modes, hooks and settings are now reachable; **packages and workers are not** (§13, §9), `Command` still has no title/category/flags, M-x rows are still bare names, and the Rust help layer is still orphaned | | 6 | Interaction islands | **Weak, and growing** | Six hardcoded key-interception shadows; no transient-keymap mechanism exists | | 7 | First-class workspaces | **Missing (conventions only)** | Marker walk + four independent consumers; no workspace object | | 8 | Execution locations | **Missing (architecture ready)** | SSH attach works; "location" is not a value anywhere | @@ -557,7 +557,25 @@ This suggests a general pmacs principle: ### Ground truth -**Grade: substrate without surface — the sharpest instance of §1.1.** +**Grade: partial — Discovery Stage 1 built the first surface.** + +Eleven `help.*` commands now render the registries that were already +there: describe-key/mode/hook/buffer/command/setting, where-is, list +commands/keybindings/settings, and a substring apropos over names and +descriptions, indexed by `M-x help`. It needed **no Rust** — the data +was all reachable from Lua, and even the settings completion source is a +Lua function through `CompletionSource::Custom`. + +**What is still missing** is itemized below and unchanged by that stage: +`Command` has no title/category/aliases/flags/arg-schema; the predicate +is still never evaluated; M-x rows are still bare name strings; the Rust +help layer is still orphaned (Stage 1 funnels every command through one +Lua seam so the eventual migration is enumerated per subject rather than +per call site); packages and workers have no discovery surface; settings +value provenance is still absent; and there is still no help prefix key. + +*The original audit grade, for reference:* **substrate without surface — +the sharpest instance of §1.1.** **What the substrate already has (genuinely strong):** diff --git a/docs/active-work.md b/docs/active-work.md index 7628388..a009815 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -380,11 +380,11 @@ which would have re-conflicted on every merge. githubsucks/journey-stage1b3-welcome ``` -## Discovery Stage 1 (P4) — FRAMING APPROVED (rev 6); implementation next +## Discovery Stage 1 (P4) — IMPLEMENTED, PR OPEN - **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 6, + based on `githubsucks/main` @ `54a092e`. **Implemented; PR open.** + `docs/discovery-stage1-command-family-framing.md` revision 6, three review rounds closed (round 1: two blocking, two major; round 2: two blocking, two major; round 3: two factual corrections; all accepted), Q#D2 / Q#D3 decided by the user, and the final review's @@ -453,6 +453,24 @@ which would have re-conflicted on every merge. 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. +- **Implementation notes.** `runtime/help.lua` is new and owns the + family plus the `help` index, which **moved out of `welcome.lua`** so + the greeting file keeps only the greeting; it loads after welcome.lua + so the index can read `pmacs.welcome.entries`. +- **The seam-counting pin caught a real bypass immediately.** The two + renamed commands were still calling the file-local `show_help_text`, + so the funnel the framing promised was fiction for exactly the two + commands that predate the seam. They now call + `pmacs.editor._show_help`, with a comment saying why the in-scope + local is deliberately not used. +- **Bites, all directed** — six mutations, each failing exactly one pin: + fuzzy apropos, name-only apropos, static where-is, a dropped + forwarder, an unindexed family command, and one command writing + `*help*` itself (seam count 10 vs 11). +- **§5 moves substrate-without-surface → Partial ON THIS PR** per §25, + with the remaining gaps named in the row: packages and workers have no + surface, `Command` still lacks title/category/flags, M-x rows are bare + names, and the Rust help layer is still orphaned. - Recovery: ```sh diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index d4542f4..2ac5266 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -300,6 +300,37 @@ commands, read `docs/active-work.md` immediately after this file. disagree — and it still establishes no identity, because it is read inside the same read-then-act window and no portable mechanism closes that for a *group* (`pidfd` covers a process; macOS has neither). +- **Discovery arc (P4) — Stage 1 IMPLEMENTED, PR open** + (`docs/discovery-stage1-command-family-framing.md`, rev 6, three + review rounds). Eleven `help.*` commands over the existing registries, + indexed by `M-x help`. **§5 moves substrate-without-surface → + Partial.** No Rust: the data was all reachable from Lua, and the + settings completion source is a Lua function through + `CompletionSource::Custom` — correcting a `default.lua` comment that + claimed `source` was a fixed Rust-side vocabulary. + - **Completion is assistance, not validation.** + `resolve_accepted_value` returns the literal typed text whenever no + candidate is selected, so a typo still reaches `on_accept`; and a + fuzzy near-miss would silently select a *different* value. Refusing + a non-candidate is Rust work, deferred. + - **`apropos` is substring, not fuzzy.** `fuzzy_score` is + subsequence-based and descriptions are long sentences, so fuzzy + matches nearly everything. Pinned by a fixture whose description + contains the needle only as a non-contiguous subsequence. + - **One owner for `*help*` writes is what the seam buys — 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. Rendering + is therefore a named per-subject function, so the future Rust work + is enumerated per subject (three new renderers) rather than + discovered per call site. + - **The seam-counting pin caught a real bypass**: the two renamed + commands were still calling the file-local `show_help_text`, so the + funnel was fiction for exactly the two that predate it. + - **`Command.predicate` is still never evaluated** — read only at + `src/help.rs:76` and one test. A preservation pin registers a + *raising* predicate and asserts the command still runs, so a stage + that starts evaluating must change that pin knowingly. - **Journey arc (P1) — Stage 1b-3 IMPLEMENTED, PR open** (`docs/journey-stage1b3-welcome-framing.md`, rev 4, three review rounds). The last of the 1b split. An unconfigured launch greets in diff --git a/docs/keybindings.md b/docs/keybindings.md index 1d3cbae..b08e4f4 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -189,8 +189,26 @@ Source: `builtin/runtime/compile.lua`. | `M-!` | `shell.command` — asynchronous output in `*shell-command*` | | `C-c c` | `compile.run` — prompts, prefilled from the detected project kind | -`M-x help` renders this file's essentials as a `*help*` buffer inside -the editor, and is what the startup welcome points at. It is the root of +`M-x help` is the **index of the discovery family**, rendered as a +`*help*` buffer inside the editor, and is what the startup welcome +points at. The family — all reachable by name, none bound to a key: + +| Command | Shows | +|---|---| +| `help.describe-command` | a command's description and bindings | +| `help.describe-setting` | a setting's type, default, effective value | +| `help.describe-key` | what a chord runs in **this** buffer | +| `help.describe-mode` | the active buffer's major mode | +| `help.describe-buffer` | the active buffer | +| `help.describe-hook` | a hook and its listeners | +| `help.where-is` | which keys run a command | +| `help.list-commands` | every command with its description | +| `help.list-keybindings` | every binding, grouped by scope | +| `help.list-settings` | every registered setting | +| `help.apropos` | substring search over names **and** descriptions | + +`editor.describe-command` and `editor.describe-setting` still work as +deprecated aliases of their `help.*` counterparts. It is the root of the eventual help family (`help.keys` and friends arrive with the discovery arc), so it takes no keybinding yet — `C-h` is **not** free: it deletes a word because non-kitty terminals cannot tell Ctrl+Backspace From 6ca757ae99f34d311626998a3b1277e23125bc9a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 19:28:52 -0400 Subject: [PATCH 09/12] =?UTF-8?q?docs(coherence):=20rewrite=20=C2=A75's=20?= =?UTF-8?q?itemized=20gaps=20for=20the=20shipped=20family?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 on #207, one finding, accepted. I moved §5's scorecard row and ground-truth grade but left the itemized "what is missing" list below them describing the pre-stage world, so the section contradicted itself. Two claims were false at this PR's head: - §4's disclosure table said keybinding search was absent ("no list-keybindings/where-is commands"). It now reads ✓ with the three commands named, and keeps the real remaining gap: no key reaches them. - §5's "missing as commands entirely" bullet listed all nine new commands and recorded `describe-setting` as free-text with no completion source. Both shipped. The bullet now states what exists, and names what genuinely remains: no discovery surface for packages or workers, no key on any of it, and completion that assists rather than validates. The superseded text is pruned rather than kept as a provenance note, per §25 — "prune superseded facts rather than appending; this is a briefing, not a log." While in the section I also corrected the `C-h` bullet, whose citation I was touching anyway. It reported the keymap's own "the key was free" comment without the reason that comment omits: non-kitty terminals cannot disambiguate Ctrl+Backspace from Ctrl+H, so rebinding breaks Ctrl+Backspace everywhere legacy. Reading it as an oversight is what would send someone at the wrong fix, and it is precisely why Stage 1 shipped the family unbound. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- COHERENCE.md | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 15a05d9..3e7603b 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -509,9 +509,10 @@ build actions, menus, missing-tool guidance): settings, profiles, package management, task definitions, frontend/language settings): -- palette △ (`M-x` fuzzy over bare names, §5) · keybinding search ✗ (no - list-keybindings/where-is commands) · workspace settings ✗ (no - workspace scope, §11) · profiles ✗ (§12) · package management ✗ +- palette △ (`M-x` fuzzy over bare names — the rows are still bare, §5) · + keybinding search ✓ (`help.list-keybindings`, `help.where-is`, + `help.describe-key`; still no key to reach them) · workspace settings + ✗ (no workspace scope, §11) · profiles ✗ (§12) · package management ✗ in-session (§13) · task definitions ✗ · frontend customization △ (themes, `pmacs.gpu.set_font`, statusline providers — all Lua-only) · language settings △ (raw Lua tables, outside the registry). @@ -627,18 +628,29 @@ the sharpest instance of §1.1.** `builtin/commands/default.lua:1103-1136` — and the one users can actually reach (`M-x editor.describe-command`) renders **less** than the unreachable one (no source, no scope, no predicate note). -- **Missing as commands entirely:** describe-key, describe-mode, - describe-hook, describe-buffer, where-is, list-commands, - list-settings, list-keybindings, apropos. What exists: - `editor.describe-command`, `editor.describe-setting`, - `editor.describe-instance[-buffer]`, `editor.list-buffers`, - `editor.list-workers`. `M-x describe-setting` prompts **free-text - with no completion source** (deliberately skipped — - `builtin/commands/default.lua:1180-1185`); a typo yields a status - line error. -- **No help prefix key.** `C-h` is `buffer.delete-word-backward` - (`builtin/keymaps/default.lua:86`, with a comment noting the key "was - free"). No `F1`, no `C-h k/f/b`. +- **The command family now exists** (Discovery Stage 1): eleven `help.*` + commands — describe-key/mode/hook/buffer/command/setting, where-is, + list-commands/keybindings/settings, apropos — indexed by `M-x help`, + with `editor.describe-command` / `editor.describe-setting` retained as + forwarders. `help.describe-setting` **now completes**, through a Lua + function passed as `CompletionSource::Custom`. + **What remains missing here:** a discovery surface for **packages and + workers** (§13, §9), and **no key reaches any of it** — the family is + `M-x`-only by design until the help-prefix decision is taken (see the + prefix bullet below). + *Completion is assistance, not validation:* `resolve_accepted_value` + returns the literal typed text when no candidate is selected, so a + typo still reaches the handler; refusing a non-candidate is unbuilt + Rust work. +- **No help prefix key**, and `C-h` is **not** available for one. + It is `buffer.delete-word-backward` because non-kitty terminals cannot + disambiguate Ctrl+Backspace from Ctrl+H — both produce byte 0x08 + (`builtin/keymaps/default.lua:78-86`), so rebinding it would break + Ctrl+Backspace on every legacy terminal. That file's own comment says + the key "was free", which reads as an oversight and is not one; §18 + records the trade. No `F1`, no `C-h k/f/b`. **This is why Discovery + Stage 1 shipped the family unbound**: the prefix is one decision for + the whole family, not nine. - **Settings value provenance is absent.** Overrides are stored as bare values (`global: HashMap`, `src/config_registry.rs:693-708`); `describe-setting`'s "Source:" is From bc2bbe0d42a8921beb2f6f832fb0de4ba30f77dd Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 20:05:06 -0400 Subject: [PATCH 10/12] docs(coherence): workers are reachable; completion is not a fixed vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 on #207, two findings, both accepted. Three places said workers have no discovery surface. They do: `editor.list-workers` opens `*workers*` via M-x, which §2's own step-11 row has recorded all along — so the document contradicted itself. Two of the three were mine, including the bullet I wrote last round while correcting a different stale claim in the same section; grepping for the phrase found only the two pre-existing sites because my own copy was split across a line break. Corrected to say what is actually missing, which is not the same thing for the two subjects: PACKAGES have no comparable surface at all, while workers have one and lack §9's ownership model and activity indicator. Collapsing those into "no discovery surface" would send a reader at the wrong work. Separately, §4's extension-surface list called completion/minibuffer sources "a fixed Rust vocabulary". `parse_completion_source` accepts a Lua function as `CompletionSource::Custom` — the mechanism this very PR uses to give `help.describe-setting` completion with no Rust. The claim now names it, since a reader who believed the old text would conclude a custom source needs binding work it does not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- COHERENCE.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 3e7603b..8a71b90 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -98,7 +98,7 @@ remain open to them. | 2 | Golden product journey | **Runs to step 10** | `pmacs .` opens the directory (1a); the interface introduces itself (1b-3); a missing language server says so (1b-2, #204); a build is bound and prefilled (1b-1, #203). Steps 1, 11 and 12 remain the thin end | | 3 | Zero-configuration state | **Partial** | Defaults genuinely strong; missing-tool failure is silent, not graceful | | 4 | Progressive disclosure | **Inverted** | The advanced level is real; the beginner level is the missing one | -| 5 | Unified discoverability | **Partial** | Discovery Stage 1: eleven `help.*` commands (describe key/mode/hook/buffer/command/setting, where-is, list commands/keybindings/settings, apropos) over the existing registries, indexed by `M-x help`. Commands, keys, modes, hooks and settings are now reachable; **packages and workers are not** (§13, §9), `Command` still has no title/category/flags, M-x rows are still bare names, and the Rust help layer is still orphaned | +| 5 | Unified discoverability | **Partial** | Discovery Stage 1: eleven `help.*` commands (describe key/mode/hook/buffer/command/setting, where-is, list commands/keybindings/settings, apropos) over the existing registries, indexed by `M-x help`. Commands, keys, modes, hooks and settings are now reachable, and `*workers*` already was (`M-x editor.list-workers`); **packages have no comparable surface** (§13), and workers still lack owner/purpose/hierarchy and any indicator (§9). `Command` still has no title/category/flags, M-x rows are still bare names, and the Rust help layer is still orphaned | | 6 | Interaction islands | **Weak, and growing** | Six hardcoded key-interception shadows; no transient-keymap mechanism exists | | 7 | First-class workspaces | **Missing (conventions only)** | Marker walk + four independent consumers; no workspace object | | 8 | Execution locations | **Missing (architecture ready)** | SSH attach works; "location" is not a value anywhere | @@ -524,8 +524,11 @@ orchestrate workers, replace interaction models): - inspect ✓ (SourceLocation on everything; no jump-to-source command though) · redefine live ✓ (`unregister` + `define`) · packages ✓ (authoring is real, §13) · new views ✓ (listview is Lua-usable) · - providers ✓ (statusline; completion/minibuffer sources are a fixed - Rust vocabulary) · keymap layers ✗ (§6 — the mechanism does not + providers ✓ (statusline; and minibuffer completion is **not** a fixed + Rust vocabulary — `parse_completion_source` accepts a Lua function as + `CompletionSource::Custom`, which is how Discovery Stage 1 gave + `help.describe-setting` completion with no Rust at all) · keymap + layers ✗ (§6 — the mechanism does not exist) · workspace policy ✗ · orchestrate workers △ (`pmacs.workers.register` funnels into builtin dispatchers, §9) · replace interaction models ✗ (the shadows, §6). @@ -572,7 +575,9 @@ Lua function through `CompletionSource::Custom`. is still never evaluated; M-x rows are still bare name strings; the Rust help layer is still orphaned (Stage 1 funnels every command through one Lua seam so the eventual migration is enumerated per subject rather than -per call site); packages and workers have no discovery surface; settings +per call site); **packages** have no discovery surface, and workers, +though `M-x editor.list-workers` opens `*workers*`, still lack the +ownership model and the activity indicator §9 asks for; settings value provenance is still absent; and there is still no help prefix key. *The original audit grade, for reference:* **substrate without surface — @@ -634,10 +639,12 @@ the sharpest instance of §1.1.** with `editor.describe-command` / `editor.describe-setting` retained as forwarders. `help.describe-setting` **now completes**, through a Lua function passed as `CompletionSource::Custom`. - **What remains missing here:** a discovery surface for **packages and - workers** (§13, §9), and **no key reaches any of it** — the family is - `M-x`-only by design until the help-prefix decision is taken (see the - prefix bullet below). + **What remains missing here:** a discovery surface for **packages** + (§13) — `*workers*` already has one, reachable by + `M-x editor.list-workers`, though §9's ownership model and activity + indicator are still absent — and **no key reaches any of this**: the + family is `M-x`-only by design until the help-prefix decision is taken + (see the prefix bullet below). *Completion is assistance, not validation:* `resolve_accepted_value` returns the literal typed text when no candidate is selected, so a typo still reaches the handler; refusing a non-candidate is unbuilt From f11af434cdba454f7a54504c0d3c490da7e2df83 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 20:15:32 -0400 Subject: [PATCH 11/12] merge: integrate main @ 28f878b; adopt the isolation seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isolation lane (#206) landed with an adoption ratchet, and it caught this branch's brand-new suite on the first run after the merge: these suites construct an editor through the ambient entry points, so they read the developer's real init.lua and write into their real data root: ["discovery_acceptance.rs (1 site(s))"] That is the ratchet working as designed against code written by someone who was not looking at the isolation lane while writing it — which is the case it exists for. `discovery_acceptance` is therefore MIGRATED, not allowlisted: it includes `common/iso.rs` and constructs through `EditorState::new_with_roots(&iso::roots())`. Allowlisting would have put a fresh ambient site into the census the same day the census was built. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- tests/discovery_acceptance.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/discovery_acceptance.rs b/tests/discovery_acceptance.rs index 7b4d021..3eea579 100644 --- a/tests/discovery_acceptance.rs +++ b/tests/discovery_acceptance.rs @@ -19,6 +19,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifi use pmacs::editor::EditorState; use pmacs::protocol::FrontendId; +#[path = "common/iso.rs"] +mod iso; + fn exec(s: &EditorState, src: &str) { s.lua_host.lua().load(src.to_owned()).exec().unwrap(); } @@ -110,8 +113,16 @@ fn run_from_palette(s: &mut EditorState, command: &str, second: Option<&str>) { } } +/// Constructed with **isolated bootstrap roots**, never ambiently. +/// +/// An integration test is compiled without `cfg(test)`, so a raw +/// `EditorState::new()` reads the developer's real `init.lua` and writes +/// bundled packages into their real data root. The adoption ratchet in +/// `ambient_isolation_acceptance` caught this suite the moment the +/// isolation lane merged — which is the ratchet doing its job against +/// brand-new code, so this file is migrated rather than allowlisted. fn editor() -> EditorState { - EditorState::new() + EditorState::new_with_roots(&iso::roots()) } /// The eleven canonical commands and the argument each needs, if any. From 5cc1a83583f2eae1f0e06b7e6d2fe81597a7516b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 20:31:00 -0400 Subject: [PATCH 12/12] fix(help): forwarders must work programmatically, not only from M-x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught this on all four test legs. The forwarder body called `pmacs.command.invoke_interactive`, which raises when the alias is reached through `pmacs.command.invoke` — and `tests/config_registry_acceptance.rs` does exactly that, three times. The acceptance pin passed throughout because it drives the M-x path, which is the path the framing spent three review rounds getting right. Being right about one entry point is not the same as covering the command, and a rename touches every caller of the old name regardless of how it is reached. Plain `invoke` is also the correct semantics rather than merely the working one: the interactive-command boundary is rotated once, by whatever entry point the user actually used, for the name they actually typed. Rotating again on the inner call would record a second boundary for a command the user never invoked. Adds `d8c`, which invokes both forwarders programmatically. Bitten by restoring `invoke_interactive`: the new pin fails alongside the three config-registry tests that found it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- builtin/runtime/help.lua | 12 +++++++++++- tests/discovery_acceptance.rs | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/builtin/runtime/help.lua b/builtin/runtime/help.lua index 5ac5096..0e31b56 100644 --- a/builtin/runtime/help.lua +++ b/builtin/runtime/help.lua @@ -380,11 +380,21 @@ pmacs.command.define { -- the bounded price of not breaking documented commands, and it carries -- a deprecation path a later stage can take. +-- `invoke`, NOT `invoke_interactive`. The forwarder must work however it +-- was itself reached, and `pmacs.command.invoke('editor.describe-setting')` +-- is a real caller (`tests/config_registry_acceptance.rs`) — CI caught +-- that, because the acceptance pin here only drove the M-x path. +-- +-- Plain `invoke` is also the correct semantics, not merely the working +-- one: the interactive-command boundary is rotated once, by whatever +-- entry point the user actually used, for the name the user actually +-- typed. Rotating again on the inner call would record a second +-- boundary for a command the user never invoked. local function forward(old_name, new_name) pmacs.command.define { name = old_name, description = string.format("Deprecated alias for `%s`.", new_name), - fn = function() pmacs.command.invoke_interactive(new_name) end, + fn = function() pmacs.command.invoke(new_name) end, } end diff --git a/tests/discovery_acceptance.rs b/tests/discovery_acceptance.rs index 3eea579..8385b13 100644 --- a/tests/discovery_acceptance.rs +++ b/tests/discovery_acceptance.rs @@ -434,6 +434,29 @@ fn d8_preservation_the_old_names_forward() { ); } +/// **P (acceptance 8, cont.)** — the forwarders also work when invoked +/// **programmatically**, not only from M-x. +/// +/// This pin exists because its absence shipped a bug: the forwarder body +/// used `invoke_interactive`, which raises when the alias is reached +/// through `pmacs.command.invoke` — a real caller in +/// `config_registry_acceptance`. The M-x pin above passed throughout, +/// because M-x is not the only way in. +#[test] +fn d8c_preservation_the_forwarders_work_programmatically() { + let s = editor(); + for old in ["editor.describe-command", "editor.describe-setting"] { + let ok: bool = eval( + &s, + &format!("return pcall(pmacs.command.invoke, {old:?}) and true or false"), + ); + assert!( + ok, + "{old} must be invocable programmatically, not only from the palette" + ); + } +} + /// **P (acceptance 8, cont.)** — the untouched list commands still work. #[test] fn d8b_preservation_list_buffers_and_workers_are_untouched() {