From 02645a71b052b1ddf258cb277c10be43d4cf1e05 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 14:28:19 -0400 Subject: [PATCH 1/8] =?UTF-8?q?docs(journey):=20frame=20Stage=201b-3=20?= =?UTF-8?q?=E2=80=94=20say=20something=20when=20the=20editor=20opens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last of the 1b split. Journey step 4 and `COHERENCE.md` §18: a fresh `pmacs` greets the user with an empty buffer, an empty status line, and no indication that `M-x` exists — the sole discovery affordance in the product is knowing to press it. Three findings shape the design: - The existing step-2 ratchet pin collides with any welcome. It asserts `status.is_empty()` while its own message says "reports no error" — the same predicate only while nothing writes a non-error status at startup. The framing corrects the assertion to its message's claim and names that as the one existing assertion the stage changes, rather than burying it in a diff. - `C-h` is not free. It is bound to word-delete because non-kitty terminals cannot disambiguate Ctrl+Backspace from Ctrl+H — both produce byte 0x08 — so rebinding it to a help prefix would break Ctrl+Backspace on every legacy terminal. §2's step-4 row calls it an oversight; it is a deliberate trade with a stated rationale. The help-prefix decision is deferred to the discovery arc with the constraint recorded so it is inherited rather than rediscovered. - A `*help*` buffer mechanism already exists over `src/help.rs`, so the welcome can point at something real. Its two gaps are recorded rather than inherited silently: it writes with delete/insert instead of `set_generated_contents`, and it is found by name. The welcome deliberately does NOT use `set_generated_contents`: that lifts read-only, discards history and marks the buffer generated, all wrong for the buffer step 5 requires the user to type into immediately. Step 4 stays Partial — the row names a welcome, a cheat sheet and `C-h`, and this closes the first plus a minimal second — so this is the first 1b stage with no landed-evidence obligation on merge. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 53 ++++ docs/journey-stage1b3-welcome-framing.md | 293 +++++++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 docs/journey-stage1b3-welcome-framing.md diff --git a/docs/active-work.md b/docs/active-work.md index 10524a4..9304ee1 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -347,6 +347,59 @@ If it does not, stop and repair the remote/fetch configuration. githubsucks/journey-stage1b1-compile-defaults ``` +## Journey Stage 1b-3 (P1) — FRAMING OPEN, revision 1 + +- **Branch `journey-stage1b3-welcome`**, worktree `../pmacs-journey-1b3`, + based on `githubsucks/main` @ `1f290d5`. **Framing only; no code, no + PR yet.** `docs/journey-stage1b3-welcome-framing.md` revision 1, no + review rounds. The last of the 1b split (1b-1 landed #203, 1b-2 is + PR #204). +- **What it is.** Journey step 4 / `COHERENCE.md` §18: a fresh `pmacs` + greets the user with an empty buffer, an empty status line, and no + indication that `M-x` exists. The stage renders a three-line welcome + into `*scratch*` and adds a minimal `M-x help`. +- **The existing step-2 pin collides with it.** + `journey_step2_launches_unconfigured_into_scratch` asserts + `status.is_empty()` while its own message says "reports no error" — + the same predicate only while nothing writes a *non-error* status at + startup. Acceptance 8 corrects the assertion to its message's claim + and calls it out rather than burying it, since the ratchet's rule is + that stages add rows. +- **`C-h` is NOT free, and the reason is load-bearing.** It is bound to + `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`). Rebinding it to a help prefix + would break Ctrl+Backspace on every legacy terminal, so §2's step-4 + row calling it an oversight is wrong: it is a deliberate trade. The + help-prefix decision is deferred to §20 Priority 4's discovery arc + with the constraint recorded. +- **`*help*` already exists** (`builtin/commands/default.lua:1226`, + `show_help_text`, reused buffer, buffer-local `q`), used by + `editor.describe-command` / `-setting`, over `src/help.rs`'s + renderers and link resolution. Two gaps recorded rather than + inherited silently: it writes with `buf:delete`/`buf:insert` instead + of `set_generated_contents`, and it is **found by name**, so a + foreign `*help*` would be cleared. +- **Deliberately NOT `set_generated_contents` for the welcome** — that + lifts read-only, discards history and marks the buffer generated, all + wrong for a buffer step 5 requires the user to type into immediately. + The one place not adopting that invariant is correct, stated so a + later audit does not "fix" it. +- **Step 4 stays Partial**, so this is the first 1b stage with no §25 + landed-evidence obligation on merge: §2's row names a welcome, a + cheat sheet *and* `C-h`, and this closes the first plus a minimal + second. +- **Integrate late.** #204 is open and touches `COHERENCE.md`, + `docs/agent-handoff.md` and `docs/active-work.md`; this lane will + conflict there. Never open a standalone refresh PR. + + ```sh + git fetch githubsucks + git worktree add ../pmacs-journey-1b3 \ + -b journey-stage1b3-welcome \ + githubsucks/journey-stage1b3-welcome + ``` + ## 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/journey-stage1b3-welcome-framing.md b/docs/journey-stage1b3-welcome-framing.md new file mode 100644 index 0000000..397b65a --- /dev/null +++ b/docs/journey-stage1b3-welcome-framing.md @@ -0,0 +1,293 @@ +# Journey Stage 1b-3 — say something when the editor opens + +**Status: framing, rev 1 — awaiting approval.** +**Serves `COHERENCE.md` §2 (the golden journey, step 4), §18 +(onboarding), §19, §20 Priority 1.** + +## 0. Revision history + +- rev 1 (2026-07-30) — first framing. Scouted against `githubsucks/main` + @ `1f290d5` (Journey Stage 1b-1, #203). + +## 1. What this stage is + +The last of the 1b split. `COHERENCE.md` §20 named three things: +**1b-1** (compile binding + defaults, landed as #203), **1b-2** (LSP +spawn guidance, PR #204), and this — the welcome buffer. + +§18 is unusually specific about the size of it: + +> the cheap floor — a welcome buffer in `*scratch*` naming `M-x`, the +> keybinding cheat sheet as a help buffer, and a help prefix decision — +> has no prerequisites at all. + +**This stage takes the first of those three.** §2's step-4 row lists all +three as what is missing, so §5 is explicit that step 4 does **not** +reach Works here, and says what remains. + +## 2. Ground truth + +Verified in the tree at `1f290d5`. + +### 2.1 A fresh launch says nothing, and the audit's citation is right + +`EditorCore::new` sets `status: String::new()` (`src/editor_core.rs:659`) +and creates `*scratch*` (`:629`). A user who runs `pmacs` with no +arguments and no config gets an empty buffer, an empty status line, and +no indication that `M-x` exists. + +`builtin/keymaps/default.lua`'s own header comment calls `M-x` the +"command palette" — the sole discovery affordance in the product is +knowing to press it. + +### 2.2 The step-2 ratchet pin will collide with any status-line welcome + +This is the finding that most shapes the design. +`tests/journey_acceptance.rs`: + +```rust +fn journey_step2_launches_unconfigured_into_scratch() { + let s = EditorState::new(); + assert_eq!(active_name(&s), "*scratch*"); + assert!( + status(&s).is_empty(), + "a clean launch reports no error; got {:?}", status(&s) + ); +} +``` + +The assertion's *message* says "reports no error"; the assertion itself +says **the status is empty**. Those are the same predicate only while +nothing ever writes a non-error status at startup — which is exactly +what a welcome would do. + +So a status-line welcome does not merely need a new row, it needs that +existing pin's predicate corrected to what its own message already +claims. §4 treats that as a deliberate, named amendment rather than a +silent edit, because the ratchet's rule is *stages add rows, none +removes them* and an assertion change deserves the same scrutiny. + +### 2.3 `C-h` is not free, and the reason is load-bearing + +§18 lists "a help prefix decision" as part of the cheap floor. It is +cheap to *decide* and not cheap to get wrong: + +```lua +-- builtin/keymaps/default.lua:78-86 +-- Why we also bind C-h: most terminals (anything not implementing the +-- kitty keyboard protocol) cannot disambiguate Ctrl+Backspace from +-- Ctrl+H — both legacy paths produce byte 0x08 … +bind("C-h", "buffer.delete-word-backward") +``` + +**Rebinding `C-h` to a help prefix would break Ctrl+Backspace on every +non-kitty terminal**, because those terminals cannot tell the two apart. +§2's step-4 row says "`C-h` deletes a word" as though it were an +oversight; it is a deliberate trade with a stated rationale. + +This stage therefore **does not touch `C-h`** (§5), and the framing +records the constraint so the discovery arc inherits the reason rather +than rediscovering it. + +### 2.4 There is already a `*help*` buffer mechanism + +`builtin/commands/default.lua:1226-1260` has `show_help_text(text)`: a +single reused `*help*` buffer, found by name, with a buffer-local `q` → +`buffer.kill-this`. `editor.describe-command` and +`editor.describe-setting` both render through it. `src/help.rs` supplies +`render_command` / `render_key` / `render_buffer` / `render_mode` / +`render_view` plus link resolution (`link_at`, `follow_link_at`). + +**Two facts about it that matter to any consumer:** + +- It writes with `buf:delete` + `buf:insert`, **not** + `pmacs.buffer.set_generated_contents`. It is one of the writer + mechanisms that has not adopted the generated-buffer write invariant, + so its buffer stays ordinarily editable and keeps its undo history. + Riding it inherits that; fixing it is not this stage's job, but a + stage that renders into it should not claim read-only guarantees it + does not have. +- It is **found by name**, which is the adoption hazard `listview` and + dired both refused. A foreign `*help*` buffer would be cleared. + +### 2.5 A modified `*scratch*` does not block quitting + +`editor.quit` runs the `editor.before-quit` hook and then `ed.quit()` +(`builtin/commands/default.lua:250-256`). There is no unsaved-buffer +confirmation, so welcome text left in `*scratch*` cannot strand a user +at exit. That removes the strongest objection to putting text in the +buffer, and it is stated because it is the kind of thing that would +otherwise be assumed in either direction. + +## 3. Design + +### 3.1 What the welcome says + +Three lines, no more. The floor is "this editor is not inert and here is +the one key that opens everything": + +``` +Welcome to pmacs. M-x run a command C-x C-f open a file + C-c c build C-c t terminal +This buffer is *scratch* — type to edit it, or M-x help for more. +``` + +Every key named must be **bound in the default keymap and verified by +the acceptance suite**, or the welcome becomes documentation drift with +a user attached. §4 pins that as a property over the message, not a +hardcoded list. + +### 3.2 Where it goes — `*scratch*`, not the status line + +The status line is the wrong vehicle even though §2's audit cites it: +it is overwritten by the next status write, and a user who blinks loses +the only pointer to `M-x`. §18 says "a welcome buffer in `*scratch*`", +and that is right for the reason the audit itself gives — *the empty +`*scratch*` buffer is what greets the user*. + +So: **welcome text is rendered into `*scratch*`**, under three +conditions, all necessary: + +1. **No arguments.** `pmacs FILE` and `pmacs DIR` both put something + else on screen; a welcome would be noise, and for the directory case + it would fight Stage 1a's dired listing. +2. **`*scratch*` is empty.** Never overwrite content — including a + restored session's scratch, or anything an `init.lua` wrote. +3. **It leaves the buffer unmodified**, so nothing about the greeting + looks like unsaved work. §2.5 shows quitting is not blocked either + way; this is about not lying in the modeline. + +### 3.3 What it must not do + +- **Not read-only.** Step 5 is "edit immediately"; a read-only greeting + would break the journey one step after it starts. The user types over + it, as in Emacs. +- **Not a new buffer.** A `*welcome*` buffer displayed instead of + `*scratch*` would change what step 2 asserts and give the user a + buffer whose only purpose is to be closed. +- **Not `set_generated_contents`.** That lifts the rope's read-only, + discards history, and marks the buffer generated — all wrong for a + buffer the user is meant to edit immediately. This is the one place + where *not* adopting the generated-buffer invariant is correct, and it + is stated so a later audit does not "fix" it. + +### 3.4 `M-x help` + +The welcome names `M-x help`, so that command has to exist. It renders +the keybinding cheat sheet through the existing `show_help_text` +mechanism (§2.4). + +**This is the smallest possible version of §18's second item**, and it +is included only because the welcome would otherwise point at nothing. +The full cheat sheet, `where-is`, `describe-key` and the help-prefix +question belong to §20 Priority 4's discovery arc (§5). + +## 4. Acceptance + +**N** = new behaviour, must fail on full revert. **P** = preservation, +falsified by a named mutation. + +1. **N — journey step 4, through the real entry point.** + `EditorState::new()` — the same construction `pmacs` with no + arguments performs — leaves `*scratch*` active **and non-empty**, and + its text names `M-x`. This is the ratchet row. +2. **N — every key the welcome names is actually bound.** Parse the key + sequences out of the rendered text and assert each resolves through + `pmacs.keymap.lookup`. A property over the message, so the pin cannot + rot when the wording changes — and so the welcome cannot advertise a + binding that a later stage removes. +3. **N — `M-x help` renders the cheat sheet** into `*help*`, containing + at least the keys the welcome names. Asserts content produced. +4. **P — the buffer is editable and unmodified.** Typing a character + into the greeted `*scratch*` inserts it (step 5 still works from the + first frame), and the buffer reports unmodified *before* that + keystroke. Targeted mutation: rendering through + `set_generated_contents`, which would make the buffer read-only and + fail the insert. +5. **P — a file argument suppresses the welcome.** `EditorState::open` + on a file leaves that file active with no greeting anywhere. + Targeted mutation: greeting unconditionally in `EditorCore::new`. +6. **P — a directory argument suppresses it too**, so Stage 1a's dired + listing is what the user sees. Same mutation; separate pin because + the directory path reaches scratch differently (the bootstrap + replaces the buffer rather than never creating it). +7. **P — a non-empty `*scratch*` is never overwritten.** Write to + scratch, then trigger the greeting path: the content survives. +8. **P (amended pin) — step 2 still reports no error.** + `journey_step2_launches_unconfigured_into_scratch` currently asserts + `status.is_empty()` while its message says "reports no error" + (§2.2). The assertion is corrected to the message's claim — no error + text on the status line — rather than deleted or weakened. **This is + the one existing assertion this stage changes**, it is called out + here rather than buried in the diff, and the row itself is kept. + +## 5. Deferred, and why + +- **The help prefix / `C-h`.** §2.3: rebinding it breaks Ctrl+Backspace + on every non-kitty terminal. The decision belongs with §20 Priority + 4's discovery arc, which can weigh a prefix (`F1`? `C-c ?`?) across + the whole command family instead of one key at a time. +- **The full cheat sheet and the discovery command family** — + `where-is`, `describe-key`, richer M-x rows: §20 Priority 4, called + "the best payoff-per-effort in this document". +- **Making `show_help_text` adopt the generated-buffer write + invariant** (§2.4). A real gap, with its own lane. +- **Onboarding proper** — §18's ten-step teaching sequence. This stage + is the floor beneath it, not a down payment on it. + +**Step 4 therefore stays Partial**, and the PR must say so: §2's row +names a welcome, a cheat sheet *and* `C-h`, and this closes the first +plus a minimal version of the second. + +## 6. Coherence impact + +- **Journey steps touched:** 4 (Partial → still Partial, with the + welcome half closed); 2 indirectly, whose pin is amended (§4.8). No + grade flips on merge, which makes this the first 1b stage with no + §25 landed-evidence obligation. +- **Interaction islands: none added.** `M-x help` renders through the + existing `*help*` mechanism rather than inventing a second help + surface — though §2.4 records that mechanism's own two gaps rather + than pretending it is exemplary. +- **Config registry:** not adopted. A `welcome.enabled` scalar would be + expressible, and is deliberately not added — a greeting the user can + delete by typing does not need a setting, and §11's adoption work + should not gain a token entry from a stage this small. +- **Background-work attribution:** unchanged; nothing here is + asynchronous. +- **Docs riding the PR:** `COHERENCE.md` §2's step-4 row and §18's + ground-truth grade (both stay Partial, with the closed half named); + `docs/keybindings.md` gains `M-x help`; `docs/agent-handoff.md` §1; + the ledger. + +## 7. Questions + +- **Q#W1 — three lines, or one?** One line ("`M-x` runs a command") + is the true floor and never wraps at 80 columns. Three teaches more + but risks looking like chrome the user must clear. Recommended: + three, because the whole complaint in §18 is that the editor teaches + nothing. +- **Q#W2 — should the welcome name `C-c c` and `C-c t`?** They are + real, bound, and journey steps 8 and 9 — but naming them means the + welcome must be updated whenever the default map changes. + Acceptance 2 turns that from a risk into a caught failure, which is + the argument for naming them. +- **Q#W3 — is `M-x help` the right name?** `help` is short and + guessable. The discovery arc may want `help.keys` / + `help.commands` as a family, and renaming later costs a deprecation. + +## 8. Ledger + +Branch `journey-stage1b3-welcome`, worktree `../pmacs-journey-1b3`, +based on `githubsucks/main` @ `1f290d5`. Framing only; no code, no PR. + +**#204 is open and touches `COHERENCE.md`, `docs/agent-handoff.md` and +`docs/active-work.md`.** This lane will conflict there; integrate late +(at PR time), never by opening a refresh PR. + +```sh +git fetch githubsucks +git worktree add ../pmacs-journey-1b3 \ + -b journey-stage1b3-welcome \ + githubsucks/journey-stage1b3-welcome +``` From 1928a3af200c81e1dcdbc8303986bc03f3b7e5a9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 14:38:45 -0400 Subject: [PATCH 2/8] =?UTF-8?q?docs(journey):=20framing=20revision=202=20?= =?UTF-8?q?=E2=80=94=20close=20review=20round=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, all accepted and verified in the tree first. The three open questions are answered and folded into the design: three lines, name `C-c c` and `C-c t` verified from structured entries, and the command is `help`. The startup seam was wrong, and every acceptance criterion rested on it. `EditorState::new()` is not the no-argument entry point: `EditorState::open` calls it before resolving the target, the daemon constructs one too, user config runs inside it, and desktop restore happens much later inside `run()`'s `RunLocal` arm. Greeting from `new()` would greet a daemon, greet before a file argument replaced the buffer, and precede anything config or a restored session put in `*scratch*`. The stage now adds a launch-finalization seam called right after `restore_desktop_if_armed`, using the `had_file` signal already threaded to that point. Its untestable part — that `run()` calls it — is stated rather than papered over, and bracketed by pins proving the seam works and that no constructor greets on its own. The step-2 pin amendment is withdrawn. Revision 1 analysed a status-line welcome and then chose `*scratch*` but kept the amendment, which was an internal contradiction. The status stays empty so the pin stays true, and "no error text" has no defined predicate over an unstructured status string — replacing an exact check with a fuzzy one would weaken the ratchet to buy nothing. The claim that this stage carried no §25 obligation was false. The scorecard's row 18 and §18's ground truth both read Missing, and a landed welcome plus reachable cheat sheet falsifies both: they move to Partial on merge while §2's step-4 row stays Partial. A stage can be too small to flip its journey step and still falsify a "missing entirely" grade. Acceptance 2 could not have been implemented as written. Scraping key sequences out of prose is ambiguous — `M-x help` mixes a chord with a command name, and `C-c c` is two chords whose boundary the text does not mark. One structured entry list now both renders the text and drives the `pmacs.keymap.lookup` checks, and `M-x help` is exercised through the real palette path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 45 ++- docs/journey-stage1b3-welcome-framing.md | 364 ++++++++++++++++------- 2 files changed, 288 insertions(+), 121 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 9304ee1..670fbfc 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -347,24 +347,34 @@ If it does not, stop and repair the remote/fetch configuration. githubsucks/journey-stage1b1-compile-defaults ``` -## Journey Stage 1b-3 (P1) — FRAMING OPEN, revision 1 +## Journey Stage 1b-3 (P1) — FRAMING OPEN, revision 2 - **Branch `journey-stage1b3-welcome`**, worktree `../pmacs-journey-1b3`, based on `githubsucks/main` @ `1f290d5`. **Framing only; no code, no - PR yet.** `docs/journey-stage1b3-welcome-framing.md` revision 1, no - review rounds. The last of the 1b split (1b-1 landed #203, 1b-2 is - PR #204). + PR yet.** `docs/journey-stage1b3-welcome-framing.md` revision 2, one + review round closed (four findings, all accepted). The last of the 1b + split (1b-1 landed #203, 1b-2 landed #204). - **What it is.** Journey step 4 / `COHERENCE.md` §18: a fresh `pmacs` greets the user with an empty buffer, an empty status line, and no indication that `M-x` exists. The stage renders a three-line welcome into `*scratch*` and adds a minimal `M-x help`. -- **The existing step-2 pin collides with it.** - `journey_step2_launches_unconfigured_into_scratch` asserts - `status.is_empty()` while its own message says "reports no error" — - the same predicate only while nothing writes a *non-error* status at - startup. Acceptance 8 corrects the assertion to its message's claim - and calls it out rather than burying it, since the ratchet's rule is - that stages add rows. +- **`EditorState::new()` is NOT the no-argument entry point**, and + revision 1 rested every criterion on the assumption that it was. + `EditorState::open` calls it *before* resolving the target + (`src/editor.rs:944`), the daemon constructs one too, user config runs + *inside* it, and desktop restore happens much later + (`restore_desktop_if_armed`, `:3637`, inside `run()`'s `RunLocal` + arm). The stage now adds a **launch-finalization seam** called right + after desktop restore, with `had_file` — already threaded to that + point for the same kind of question — as the no-target signal. + Residual, stated: `run()` takes over the terminal, so *that it calls + the seam* is reviewed, not tested; pins bracket it by proving the seam + works and that no constructor greets on its own. +- **The step-2 pin is NOT amended.** Revision 1 analysed a status-line + welcome and then chose `*scratch*`, but kept the amendment — an + internal contradiction. The status stays empty, so the pin stays true, + and "no error text" has no defined predicate over an unstructured + status string anyway. - **`C-h` is NOT free, and the reason is load-bearing.** It is bound to `buffer.delete-word-backward` because non-kitty terminals cannot disambiguate Ctrl+Backspace from Ctrl+H — both produce byte 0x08 @@ -385,10 +395,15 @@ If it does not, stop and repair the remote/fetch configuration. wrong for a buffer step 5 requires the user to type into immediately. The one place not adopting that invariant is correct, stated so a later audit does not "fix" it. -- **Step 4 stays Partial**, so this is the first 1b stage with no §25 - landed-evidence obligation on merge: §2's row names a welcome, a - cheat sheet *and* `C-h`, and this closes the first plus a minimal - second. +- **Step 4 stays Partial, but there IS a §25 obligation** — revision 1 + claimed there was none. The scorecard's row 18 and §18's ground truth + both read **Missing**, and both become false on merge: they move to + **Partial**. A stage can be too small to flip its journey step while + still falsifying a "missing entirely" grade. +- **The welcome renders from a structured entry list**, not from scraped + prose: `M-x help` mixes a chord with a command name and `C-c c` is two + chords whose boundary prose does not mark, so the binding checks run + `pmacs.keymap.lookup` over the same list that renders the text. - **Integrate late.** #204 is open and touches `COHERENCE.md`, `docs/agent-handoff.md` and `docs/active-work.md`; this lane will conflict there. Never open a standalone refresh PR. diff --git a/docs/journey-stage1b3-welcome-framing.md b/docs/journey-stage1b3-welcome-framing.md index 397b65a..38dab50 100644 --- a/docs/journey-stage1b3-welcome-framing.md +++ b/docs/journey-stage1b3-welcome-framing.md @@ -1,11 +1,44 @@ # Journey Stage 1b-3 — say something when the editor opens -**Status: framing, rev 1 — awaiting approval.** +**Status: framing, rev 2 — awaiting review round 2.** **Serves `COHERENCE.md` §2 (the golden journey, step 4), §18 (onboarding), §19, §20 Priority 1.** ## 0. Revision history +- rev 2 (2026-07-31) — review round 1. Four findings, all accepted, all + verified in the tree first. §7's three questions are answered and + folded into the design. + - **The startup seam was wrong.** `EditorState::new()` is not the + no-argument entry point: `EditorState::open` calls it + (`src/editor.rs:944`) *before* handling the file or directory, the + daemon constructs it too, user config runs *inside* it, and desktop + restore happens later still (`:3637`). Greeting from `new()` would + greet a daemon, greet before a file argument replaced the buffer, + and run before config or restore could put anything in `*scratch*`. + §3.2 now defines a **launch-finalization seam** that runs after + config, after attach dispatch resolves to local, and after desktop + restore — and §4 pins the three paths that must *not* greet. + - **The status-pin amendment was unnecessary and weakened a stronger + ratchet.** Rev 1 analysed a status-line welcome in §2.2 and then + chose `*scratch*` in §3.2, but kept the amendment — an internal + contradiction. With the chosen design + `journey_step2_launches_unconfigured_into_scratch` stays correct as + written, and "no error text" has no defined predicate over an + unstructured status string anyway. **The pin is untouched**; the + welcome gets its own row. + - **"No §25 landed-evidence obligation" was false.** The scorecard + row 18 reads **Missing** and §18's ground truth says "missing + entirely" — both are audited claims that a landed welcome plus + interactive help changes. §6 now records the obligation: §18 and the + scorecard move **Missing → Partial** on merge, while §2's step-4 row + stays Partial. + - **Acceptance 2 could not be implemented as written.** "Parse the key + sequences out of the rendered text" is ambiguous: `M-x help` mixes a + chord with a command name, and multi-chord sequences like `C-c c` + cannot be scraped unambiguously from prose. §3.1 now specifies a + **structured entry list** that both renders the text and drives the + binding checks. - rev 1 (2026-07-30) — first framing. Scouted against `githubsucks/main` @ `1f290d5` (Journey Stage 1b-1, #203). @@ -40,32 +73,59 @@ no indication that `M-x` exists. "command palette" — the sole discovery affordance in the product is knowing to press it. -### 2.2 The step-2 ratchet pin will collide with any status-line welcome +### 2.2 `EditorState::new()` is not the no-argument entry point -This is the finding that most shapes the design. -`tests/journey_acceptance.rs`: +Rev 1 assumed it was, and every acceptance criterion rested on that. +It is wrong four ways, all in `src/editor.rs`: + +- **`EditorState::open` calls it first** (`:944`), *then* resolves the + target. So a greeting inside `new()` fires before the file or + directory is handled, and the `*scratch*` buffer survives that + handling — `replace_active_buffer` reassigns the window's buffer and + removes nothing. +- **The daemon constructs one too**, so a greeting there is written into + a session no human is looking at. +- **User `init.lua` runs inside `new()`**, so a greeting written there + precedes anything config might put in `*scratch*`. +- **Desktop restore happens much later** — `restore_desktop_if_armed` + at `:3637`, inside the `RunLocal` arm of `run()`, after + `install_state_dirs` and after attach dispatch. A restored session can + populate `*scratch*`, and a greeting from `new()` would already have + written into it. + +`run()`'s shape is what defines the correct seam: ```rust -fn journey_step2_launches_unconfigured_into_scratch() { - let s = EditorState::new(); - assert_eq!(active_name(&s), "*scratch*"); - assert!( - status(&s).is_empty(), - "a clean launch reports no error; got {:?}", status(&s) - ); -} +let mut state = match target { + Some(path) => EditorState::open(path)?, // config runs inside + None => EditorState::new(), // …and here +}; +state.install_state_dirs(); +let requested = state.lua_host.take_requested_attach(); +match dispatch_attach(requested) { + RunLocal => { + state.restore_desktop_if_armed(had_file); // :3637 + // ← the only correct place to greet ``` -The assertion's *message* says "reports no error"; the assertion itself -says **the status is empty**. Those are the same predicate only while -nothing ever writes a non-error status at startup — which is exactly -what a welcome would do. +Note `had_file` is already threaded to exactly this point for exactly +this kind of question ("a positional argument means *open this*, not +*restore my desktop*"), so the no-target signal does not need inventing. -So a status-line welcome does not merely need a new row, it needs that -existing pin's predicate corrected to what its own message already -claims. §4 treats that as a deliberate, named amendment rather than a -silent edit, because the ratchet's rule is *stages add rows, none -removes them* and an assertion change deserves the same scrutiny. +### 2.2a The step-2 pin stays as it is + +Rev 1 proposed amending +`journey_step2_launches_unconfigured_into_scratch`'s +`assert!(status(&s).is_empty())`. **That was left over from a +status-line design rev 1 then rejected**, and keeping both was an +internal contradiction. + +The welcome goes into `*scratch*` (§3.2), so the status line stays empty +and the pin stays true as written. It is also the stronger assertion: +"no *error* text" has no defined predicate over an unstructured status +string, so replacing an exact check with a fuzzy one would weaken the +ratchet to buy nothing. **The pin is untouched**; the welcome gets its +own row (§4). ### 2.3 `C-h` is not free, and the reason is load-bearing @@ -121,21 +181,52 @@ otherwise be assumed in either direction. ## 3. Design -### 3.1 What the welcome says +### 3.1 What the welcome says, and the shape it is built from -Three lines, no more. The floor is "this editor is not inert and here is -the one key that opens everything": +**Three lines** (Q#W1), naming `C-c c` and `C-c t` (Q#W2): ``` -Welcome to pmacs. M-x run a command C-x C-f open a file - C-c c build C-c t terminal -This buffer is *scratch* — type to edit it, or M-x help for more. +Welcome to pmacs. M-x runs any command; M-x help lists the keys. + C-x C-f open a file C-c t terminal + C-c c build C-x b switch buffer ``` -Every key named must be **bound in the default keymap and verified by -the acceptance suite**, or the welcome becomes documentation drift with -a user attached. §4 pins that as a property over the message, not a -hardcoded list. +**The text is rendered from a structured list, never scraped back out +of it.** Rev 1 said the acceptance would "parse the key sequences out of +the rendered text"; that cannot be implemented reliably — `M-x help` +puts a chord and a command name in one phrase, and `C-c c` is two chords +whose boundary prose does not mark. + +One list is the single source for both the rendering and the checks: + +```lua +-- Each entry is { keys = "", label = "" }. +-- `keys` is EXACTLY what `pmacs.keymap.lookup` accepts, so a binding +-- check is a lookup, not a guess about where a chord ends. +pmacs.welcome.entries = { + { keys = "C-x C-f", label = "open a file" }, + { keys = "C-c t", label = "terminal" }, + { keys = "C-c c", label = "build" }, + { keys = "C-x b", label = "switch buffer" }, +} +``` + +`M-x` and `M-x help` are prose in the first line rather than entries: +`M-x` is the palette itself and `help` is a command name, so neither is +a keymap lookup. The acceptance checks the command exists instead (§4.3). + +**Every entry must resolve through `pmacs.keymap.lookup`**, asserted as +a property over the list (§4.2). That is what stops the welcome becoming +documentation drift with a user attached — and it is why the list is +public: a user who rebinds can rebuild it. + +### 3.1a `M-x help` + +Named `help` (Q#W3). It renders the cheat sheet through the existing +`show_help_text` mechanism (§2.4) and is the **root of the eventual +family**: when the discovery arc adds `help.keys`, `help.commands` and +friends, `help` remains the index they are reached from, so no +deprecation is owed. ### 3.2 Where it goes — `*scratch*`, not the status line @@ -145,17 +236,45 @@ the only pointer to `M-x`. §18 says "a welcome buffer in `*scratch*`", and that is right for the reason the audit itself gives — *the empty `*scratch*` buffer is what greets the user*. -So: **welcome text is rendered into `*scratch*`**, under three -conditions, all necessary: +### 3.2a **When** it goes — a launch-finalization seam -1. **No arguments.** `pmacs FILE` and `pmacs DIR` both put something - else on screen; a welcome would be noise, and for the directory case - it would fight Stage 1a's dired listing. -2. **`*scratch*` is empty.** Never overwrite content — including a - restored session's scratch, or anything an `init.lua` wrote. -3. **It leaves the buffer unmodified**, so nothing about the greeting - looks like unsaved work. §2.5 shows quitting is not blocked either - way; this is about not lying in the modeline. +Per §2.2, no existing constructor is the right hook. The stage adds one +named seam: + +```rust +/// Final step of a LOCAL, no-target launch, after config, attach +/// dispatch and desktop restore have all had their say. The only +/// caller is `run()`'s `RunLocal` arm; the only thing it does is +/// conditionally greet an untouched `*scratch*`. +pub fn finalize_local_launch(&mut self, had_file: bool) +``` + +called from `run()` immediately after `restore_desktop_if_armed(had_file)`. + +It greets only when **all four** hold, and each excludes a case §2.2 +showed rev 1 would have got wrong: + +1. **`had_file` is false** — a positional argument means "open this". +2. **The session is local** — it is inside the `RunLocal` arm, so a + daemon or an attach hand-off never reaches it. +3. **`*scratch*` is the active buffer** — desktop restore may have put + something else in front. +4. **`*scratch*` is empty** — never overwrite config's or a restored + session's content. + +And it leaves the buffer **unmodified**, so the greeting does not look +like unsaved work in the modeline. §2.5 shows quitting is not blocked +either way; this is about not lying. + +**Honest limit on the seam's testability.** `run()` takes over the +terminal, so no test drives it end to end. The acceptance therefore pins +(a) the seam's own behaviour under each condition, and (b) that the +three constructors — `new()`, `open(file)`, `open(dir)` — greet +**nothing** on their own, which is what makes the seam the only writer. +What remains unpinned is `run()` actually calling it; that is stated +here rather than papered over, and it is the reason the seam is a single +named function with one call site rather than logic inlined into the +arm. ### 3.3 What it must not do @@ -171,55 +290,75 @@ conditions, all necessary: where *not* adopting the generated-buffer invariant is correct, and it is stated so a later audit does not "fix" it. -### 3.4 `M-x help` +### 3.4 `M-x help` is deliberately minimal -The welcome names `M-x help`, so that command has to exist. It renders -the keybinding cheat sheet through the existing `show_help_text` -mechanism (§2.4). - -**This is the smallest possible version of §18's second item**, and it -is included only because the welcome would otherwise point at nothing. -The full cheat sheet, `where-is`, `describe-key` and the help-prefix -question belong to §20 Priority 4's discovery arc (§5). +It exists only because the welcome would otherwise point at nothing. +**This is the smallest possible version of §18's second item**: the full +cheat sheet, `where-is`, `describe-key` and the help-prefix question all +belong to §20 Priority 4's discovery arc (§5). ## 4. Acceptance **N** = new behaviour, must fail on full revert. **P** = preservation, falsified by a named mutation. -1. **N — journey step 4, through the real entry point.** - `EditorState::new()` — the same construction `pmacs` with no - arguments performs — leaves `*scratch*` active **and non-empty**, and - its text names `M-x`. This is the ratchet row. -2. **N — every key the welcome names is actually bound.** Parse the key - sequences out of the rendered text and assert each resolves through - `pmacs.keymap.lookup`. A property over the message, so the pin cannot - rot when the wording changes — and so the welcome cannot advertise a - binding that a later stage removes. -3. **N — `M-x help` renders the cheat sheet** into `*help*`, containing - at least the keys the welcome names. Asserts content produced. -4. **P — the buffer is editable and unmodified.** Typing a character - into the greeted `*scratch*` inserts it (step 5 still works from the - first frame), and the buffer reports unmodified *before* that - keystroke. Targeted mutation: rendering through - `set_generated_contents`, which would make the buffer read-only and - fail the insert. -5. **P — a file argument suppresses the welcome.** `EditorState::open` - on a file leaves that file active with no greeting anywhere. - Targeted mutation: greeting unconditionally in `EditorCore::new`. -6. **P — a directory argument suppresses it too**, so Stage 1a's dired - listing is what the user sees. Same mutation; separate pin because - the directory path reaches scratch differently (the bootstrap - replaces the buffer rather than never creating it). -7. **P — a non-empty `*scratch*` is never overwritten.** Write to - scratch, then trigger the greeting path: the content survives. -8. **P (amended pin) — step 2 still reports no error.** - `journey_step2_launches_unconfigured_into_scratch` currently asserts - `status.is_empty()` while its message says "reports no error" - (§2.2). The assertion is corrected to the message's claim — no error - text on the status line — rather than deleted or weakened. **This is - the one existing assertion this stage changes**, it is called out - here rather than buried in the diff, and the row itself is kept. +1. **N — journey step 4: a no-target local launch greets.** + Construct through `EditorState::new()`, then call + `finalize_local_launch(false)` — the seam `run()` calls. `*scratch*` + is active and **non-empty**, and its text names `M-x`. This is the + ratchet row. + *Rev 1 claimed `EditorState::new()` was itself the entry point; §2.2 + shows it is shared with `open()` and the daemon, so the pin drives the + seam instead.* +2. **N — every entry the welcome names is bound.** For each + `pmacs.welcome.entries` item, `pmacs.keymap.lookup(entry.keys)` + resolves. A property over the **structured list** (§3.1), not a scrape + of prose — so it cannot rot when the wording changes, and it fails + loudly if a later stage unbinds something the welcome advertises. + Includes a precondition that the list is non-empty, or the loop is + vacuous. +3. **N — the rendered text contains every entry's `keys` and `label`.** + This is what ties the list to the thing the user actually sees; pin 2 + alone would pass if rendering dropped an entry. +4. **N — `M-x help` renders the cheat sheet** into `*help*`, containing + at least the entries' key sequences. **Invoked through the real + palette path** (`pmacs.command.invoke`), not by calling the render + helper, so the command is proven reachable the way a user reaches it. +5. **P — the buffer is editable and unmodified.** After greeting, + `*scratch*` reports unmodified; typing a character inserts it, so + step 5 works from the first frame. Targeted mutation: rendering + through `set_generated_contents`, which would lift read-only, discard + history, and make the insert fail. +6. **P — a file target does not greet.** `EditorState::open(file)` then + `finalize_local_launch(true)`: no welcome anywhere, and the file is + active. Targeted mutation: dropping the `had_file` guard. +7. **P — a directory target does not greet.** Same with a directory, so + Stage 1a's dired listing is untouched. Separate pin because the + directory path reaches `*scratch*` differently — the bootstrap + replaces the window's buffer and `replace_active_buffer` removes + nothing, so the scratch buffer still exists to be wrongly greeted. +8. **P — a non-empty `*scratch*` is never overwritten.** Write to + scratch (standing in for config or a restored desktop), then run the + seam: the content survives byte for byte. Targeted mutation: dropping + the emptiness guard. +9. **P — a non-active `*scratch*` is not greeted.** Switch the active + buffer away, then run the seam: scratch stays empty. Stands in for + desktop restore having put something else in front. Targeted + mutation: dropping the active-buffer guard. +10. **P — the three constructors greet nothing on their own.** + `EditorState::new()`, `open(file)` and `open(dir)`, each with no + seam call, leave `*scratch*` empty. **This is what makes the seam the + only writer**, and it is the pin that would catch a greeting + smuggled back into a constructor — including the daemon's. +11. **P — the step-2 pin is unchanged and still passes.** + `journey_step2_launches_unconfigured_into_scratch` keeps asserting + an empty status, because the welcome goes to the buffer (§2.2a). Not + a new test — a stated requirement that this stage does not touch it. + +**What is not pinned, stated rather than implied:** that `run()` calls +`finalize_local_launch`. `run()` takes over the terminal and no test +drives it (§3.2a). Pins 1 and 10 bracket the risk — the seam works, and +nothing else greets — but the wiring itself is reviewed, not tested. ## 5. Deferred, and why @@ -237,14 +376,30 @@ falsified by a named mutation. **Step 4 therefore stays Partial**, and the PR must say so: §2's row names a welcome, a cheat sheet *and* `C-h`, and this closes the first -plus a minimal version of the second. +plus a minimal version of the second. **§18 and the scorecard do move**, +Missing → Partial (§6) — a stage can be too small to flip its journey +step while still falsifying a "missing entirely" grade. ## 6. Coherence impact -- **Journey steps touched:** 4 (Partial → still Partial, with the - welcome half closed); 2 indirectly, whose pin is amended (§4.8). No - grade flips on merge, which makes this the first 1b stage with no - §25 landed-evidence obligation. +- **Journey steps touched:** 4 — **and it stays Partial**, because §2's + row names a welcome, a cheat sheet *and* `C-h`, and this closes the + first plus a minimal second. Step 2 is not touched at all (§2.2a). +- **§25 landed-evidence obligation: yes, and rev 1 said otherwise.** + Two audited claims change on merge and both must move + **Missing → Partial**: + - the **scorecard** row 18, "Onboarding | **Missing** | No welcome, no + tutorial; `C-h` deletes a word; `M-x` is the only door in"; + - **§18's ground truth**, "Grade: missing entirely. No welcome buffer, + … no cheat sheet reachable from inside the editor". + + Both become false the moment this lands — a welcome buffer exists and + a cheat sheet is reachable by `M-x help` — while `C-h` and the tutorial + stay untrue, which is what makes the new grade Partial rather than + Works. §25 requires the update to ride the landing PR. Rev 1's claim + that this stage had no such obligation was simply wrong, and would + have left the document asserting "missing entirely" about a feature + the same PR shipped. - **Interaction islands: none added.** `M-x help` renders through the existing `*help*` mechanism rather than inventing a second help surface — though §2.4 records that mechanism's own two gaps rather @@ -255,26 +410,23 @@ plus a minimal version of the second. should not gain a token entry from a stage this small. - **Background-work attribution:** unchanged; nothing here is asynchronous. -- **Docs riding the PR:** `COHERENCE.md` §2's step-4 row and §18's - ground-truth grade (both stay Partial, with the closed half named); - `docs/keybindings.md` gains `M-x help`; `docs/agent-handoff.md` §1; - the ledger. +- **Docs riding the PR:** `COHERENCE.md`'s **scorecard row 18** and + **§18's ground truth** (Missing → **Partial**), §2's **step-4 row** + (stays Partial, with the closed half named), and §18's own note that + the cheap floor's first two items are done and the help-prefix + decision is not; `docs/keybindings.md` gains `M-x help`; + `docs/agent-handoff.md` §1; the ledger. -## 7. Questions +## 7. Questions — answered in review round 1 -- **Q#W1 — three lines, or one?** One line ("`M-x` runs a command") - is the true floor and never wraps at 80 columns. Three teaches more - but risks looking like chrome the user must clear. Recommended: - three, because the whole complaint in §18 is that the editor teaches - nothing. -- **Q#W2 — should the welcome name `C-c c` and `C-c t`?** They are - real, bound, and journey steps 8 and 9 — but naming them means the - welcome must be updated whenever the default map changes. - Acceptance 2 turns that from a risk into a caught failure, which is - the argument for naming them. -- **Q#W3 — is `M-x help` the right name?** `help` is short and - guessable. The discovery arc may want `help.keys` / - `help.commands` as a family, and renaming later costs a deprecation. +- **Q#W1 — three lines, or one? → three.** The whole complaint in §18 is + that the editor teaches nothing, so the floor is not one line. +- **Q#W2 — name `C-c c` and `C-c t`? → yes**, and verify them from the + structured entries (§3.1). Naming real bindings is the point; pin 2 + turns the maintenance risk into a caught failure rather than drift. +- **Q#W3 — is `help` the right name? → yes.** It stays the **root/index** + when `help.keys` and friends arrive under the discovery arc, so no + future rename or deprecation is owed. ## 8. Ledger From d8c6328872d0455526047ddc357905c226ae5568 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 14:45:24 -0400 Subject: [PATCH 3/8] =?UTF-8?q?docs(journey):=20framing=20revision=203=20?= =?UTF-8?q?=E2=80=94=20close=20review=20round=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two acceptance holes and one doc correction, all accepted. The real startup wiring was still unpinned, and revision 2 knew it: it named the gap and then accepted it as residual, which is worse than missing it. Deleting the sole `run()` call to `finalize_local_launch` would have left every proposed pin green while shipping no welcome — pin 1 called the seam by hand and pin 10 only proved constructors were blank. That is the shape of a guard with no production caller passing every direct-call test. The fix is extraction, not disclaimer. Everything in `run()` from `install_panic_hook` through the end of the attach-dispatch match is terminal-free; `Frontend::new()` is where takeover begins. That prefix becomes `prepare_startup`, which `run()` delegates to and which the welcome pin now drives. Deleting the finalize call inside it turns the pin red. What stays outside a test is `Frontend::new()` and the event loop, which is where terminal takeover genuinely lives. Acceptance 4 was not the M-x path. `pmacs.command.invoke` is the programmatic API; M-x is `editor.execute-command`, which opens a minibuffer with the `commands` completion source and calls `invoke_interactive` only on accept. The pin now dispatches the chord, enters the name, and accepts — and names the hazard that makes that non-trivial: a selected candidate shadows typed text, so the pin has to assert which command ran rather than trusting that some help buffer appeared. dired refused a completion source on its own prompt for exactly this reason. Also corrects the #204 references: revision 2's history said it had landed while the framing footer and ledger still called it open. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 33 ++++-- docs/journey-stage1b3-welcome-framing.md | 143 ++++++++++++++++++----- 2 files changed, 136 insertions(+), 40 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 670fbfc..525ab4d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -347,13 +347,14 @@ If it does not, stop and repair the remote/fetch configuration. githubsucks/journey-stage1b1-compile-defaults ``` -## Journey Stage 1b-3 (P1) — FRAMING OPEN, revision 2 +## Journey Stage 1b-3 (P1) — FRAMING OPEN, revision 3 - **Branch `journey-stage1b3-welcome`**, worktree `../pmacs-journey-1b3`, based on `githubsucks/main` @ `1f290d5`. **Framing only; no code, no - PR yet.** `docs/journey-stage1b3-welcome-framing.md` revision 2, one - review round closed (four findings, all accepted). The last of the 1b - split (1b-1 landed #203, 1b-2 landed #204). + PR yet.** `docs/journey-stage1b3-welcome-framing.md` revision 3, two + review rounds closed (round 1: four findings; round 2: two acceptance + holes plus a doc correction; all accepted). The last of the 1b split + (1b-1 landed #203, 1b-2 landed #204 at `5376af1`). - **What it is.** Journey step 4 / `COHERENCE.md` §18: a fresh `pmacs` greets the user with an empty buffer, an empty status line, and no indication that `M-x` exists. The stage renders a three-line welcome @@ -367,9 +368,13 @@ If it does not, stop and repair the remote/fetch configuration. arm). The stage now adds a **launch-finalization seam** called right after desktop restore, with `had_file` — already threaded to that point for the same kind of question — as the no-target signal. - Residual, stated: `run()` takes over the terminal, so *that it calls - the seam* is reviewed, not tested; pins bracket it by proving the seam - works and that no constructor greets on its own. + **Round 2 closed the wiring hole rather than disclaiming it.** + Revision 2 called `run()`'s call to the seam an untestable residual — + but deleting that one line left every proposed pin green while + shipping no welcome, because the pins called the seam by hand. The + terminal-free prefix of `run()` (everything before `Frontend::new()`) + is now extracted into `prepare_startup`, which `run()` delegates to + and the pins drive. - **The step-2 pin is NOT amended.** Revision 1 analysed a status-line welcome and then chose `*scratch*`, but kept the amendment — an internal contradiction. The status stays empty, so the pin stays true, @@ -404,9 +409,17 @@ If it does not, stop and repair the remote/fetch configuration. prose: `M-x help` mixes a chord with a command name and `C-c c` is two chords whose boundary prose does not mark, so the binding checks run `pmacs.keymap.lookup` over the same list that renders the text. -- **Integrate late.** #204 is open and touches `COHERENCE.md`, - `docs/agent-handoff.md` and `docs/active-work.md`; this lane will - conflict there. Never open a standalone refresh PR. +- **`pmacs.command.invoke` is NOT the M-x path** — it is the + programmatic API. M-x is `editor.execute-command`, a minibuffer with + the `commands` completion source that calls `invoke_interactive` on + accept. The `M-x help` pin dispatches the chord, enters the name and + accepts — and must assert **which** command ran, because a selected + candidate shadows typed text (dired refused a completion source for + exactly this reason). +- **Integrate late.** #204 has landed at `5376af1`; this lane still + touches `COHERENCE.md`, `docs/agent-handoff.md` and + `docs/active-work.md`, so merge `main` at PR time rather than opening + a standalone refresh PR. ```sh git fetch githubsucks diff --git a/docs/journey-stage1b3-welcome-framing.md b/docs/journey-stage1b3-welcome-framing.md index 38dab50..f62c570 100644 --- a/docs/journey-stage1b3-welcome-framing.md +++ b/docs/journey-stage1b3-welcome-framing.md @@ -1,11 +1,34 @@ # Journey Stage 1b-3 — say something when the editor opens -**Status: framing, rev 2 — awaiting review round 2.** +**Status: framing, rev 3 — awaiting review round 3.** **Serves `COHERENCE.md` §2 (the golden journey, step 4), §18 (onboarding), §19, §20 Priority 1.** ## 0. Revision history +- rev 3 (2026-07-31) — review round 2. Two acceptance holes and one doc + correction; all accepted. + - **The real startup wiring was still unpinned, and rev 2 knew it.** + Rev 2 named the gap in §3.2a and then accepted it as "residual", + which is worse than missing it: deleting the sole `run()` call to + `finalize_local_launch` would leave **every** proposed pin green + while shipping no welcome at all. Pin 1 called the seam by hand and + pin 10 only proved constructors were blank. §3.2b now **extracts the + non-terminal part of `run()`** into a production helper that `run()` + delegates to, and the pins drive that helper — terminal takeover + stays outside the test, which is the only part that ever needed to + be. + - **Acceptance 4 was not the M-x path.** `pmacs.command.invoke` + (`src/lua_bindings/mod.rs:6062`) is the *programmatic* API. M-x is + `editor.execute-command`, which opens a minibuffer with the + `commands` completion source and calls **`invoke_interactive`** only + once the user accepts (`builtin/commands/default.lua:743-755`). The + pin now dispatches `M-x`, enters `help`, and accepts — and §4 names + the candidate-shadowing hazard that makes "type and press RET" + non-trivial with a completion source attached. + - **1b-2 has landed.** Rev 2's history said so while §8 and the ledger + still called #204 open; `githubsucks/main` @ `5376af1` is the merge. + Corrected. - rev 2 (2026-07-31) — review round 1. Four findings, all accepted, all verified in the tree first. §7's three questions are answered and folded into the design. @@ -46,7 +69,7 @@ The last of the 1b split. `COHERENCE.md` §20 named three things: **1b-1** (compile binding + defaults, landed as #203), **1b-2** (LSP -spawn guidance, PR #204), and this — the welcome buffer. +spawn guidance, landed as #204), and this — the welcome buffer. §18 is unusually specific about the size of it: @@ -266,15 +289,55 @@ And it leaves the buffer **unmodified**, so the greeting does not look like unsaved work in the modeline. §2.5 shows quitting is not blocked either way; this is about not lying. -**Honest limit on the seam's testability.** `run()` takes over the -terminal, so no test drives it end to end. The acceptance therefore pins -(a) the seam's own behaviour under each condition, and (b) that the -three constructors — `new()`, `open(file)`, `open(dir)` — greet -**nothing** on their own, which is what makes the seam the only writer. -What remains unpinned is `run()` actually calling it; that is stated -here rather than papered over, and it is the reason the seam is a single -named function with one call site rather than logic inlined into the -arm. +### 3.2b The wiring is pinned by extracting it, not by disclaiming it + +Rev 2 stopped here and called `run()`'s call to the seam an untestable +residual. That is not good enough: **delete that one line and every pin +rev 2 proposed still passes**, because pin 1 called the seam by hand and +pin 10 only asserted that constructors greet nothing. The product would +ship with no welcome and a green suite — the exact shape of "a guard +with no production caller passes every direct-call test". + +The boundary is already clean. Everything in `run()` from +`install_panic_hook()` through the end of the attach-dispatch `match` is +terminal-free; `Frontend::new()?` is where takeover begins. So that +prefix becomes a helper: + +```rust +/// Everything `run` does before it touches the terminal: construct from +/// the target, install state dirs, dispatch the init-time attach +/// request, and — on the local path — restore the desktop and finalize +/// the launch. +/// +/// Extracted so the local-startup sequence is testable. `run` adds only +/// `Frontend::new()` and the event loop. +pub(crate) fn prepare_startup(file: Option) -> io::Result; + +pub(crate) enum Startup { + /// Ready to enter the local TUI loop — desktop restored, launch + /// finalized. + Local(EditorState), + /// A hand-off the caller must perform; it takes the terminal, so it + /// stays out of here. + HandOff(crate::attach_dispatch::AttachDispatch), +} +``` + +`run()` becomes `match prepare_startup(file)? { … }` plus the loop it +already has. The attach arms keep calling `run_attach*` from `run()`, +because those take over the terminal. + +**What this buys:** the welcome pin now drives `prepare_startup(None)` — +production code, the same call `run()` makes — so deleting the +`finalize_local_launch` call inside it turns the pin red. The only thing +still outside a test is `Frontend::new()` and the event loop, which is +where terminal takeover genuinely lives and which this stage does not +touch. + +**What remains true and small:** `run()` could in principle stop calling +`prepare_startup`. But then it has no `EditorState` at all and does not +compile — the risk collapses to nothing a reviewer could miss, which is +the difference between this and rev 2's disclaimer. ### 3.3 What it must not do @@ -302,14 +365,16 @@ belong to §20 Priority 4's discovery arc (§5). **N** = new behaviour, must fail on full revert. **P** = preservation, falsified by a named mutation. -1. **N — journey step 4: a no-target local launch greets.** - Construct through `EditorState::new()`, then call - `finalize_local_launch(false)` — the seam `run()` calls. `*scratch*` - is active and **non-empty**, and its text names `M-x`. This is the - ratchet row. - *Rev 1 claimed `EditorState::new()` was itself the entry point; §2.2 - shows it is shared with `open()` and the daemon, so the pin drives the - seam instead.* +1. **N — journey step 4, through the production startup path.** + Drive **`prepare_startup(None)`** (§3.2b) — the same call `run()` + makes — and assert the returned `Startup::Local` state has `*scratch*` + active, **non-empty**, naming `M-x`. This is the ratchet row. + *Rev 1 claimed `EditorState::new()` was the entry point (§2.2 shows + it is shared with `open()` and the daemon); rev 2 then called the seam + by hand, which left the wiring unpinned (§3.2b). This drives neither + — it drives what `run()` drives.* + **Falsified by deleting the `finalize_local_launch` call inside + `prepare_startup`**, which is the mutation rev 2's pins survived. 2. **N — every entry the welcome names is bound.** For each `pmacs.welcome.entries` item, `pmacs.keymap.lookup(entry.keys)` resolves. A property over the **structured list** (§3.1), not a scrape @@ -320,10 +385,23 @@ falsified by a named mutation. 3. **N — the rendered text contains every entry's `keys` and `label`.** This is what ties the list to the thing the user actually sees; pin 2 alone would pass if rendering dropped an entry. -4. **N — `M-x help` renders the cheat sheet** into `*help*`, containing - at least the entries' key sequences. **Invoked through the real - palette path** (`pmacs.command.invoke`), not by calling the render - helper, so the command is proven reachable the way a user reaches it. +4. **N — `M-x help` renders the cheat sheet**, reached the way a user + reaches it. `pmacs.command.invoke` is the **programmatic** API + (`src/lua_bindings/mod.rs:6062`) and is *not* the M-x path: M-x is + `editor.execute-command`, which opens a minibuffer with the + `commands` completion source and calls **`invoke_interactive`** only + on accept (`builtin/commands/default.lua:743-755`). So the pin + **dispatches `M-x` as a key**, enters `help`, and accepts — then + asserts `*help*` contains the entries' key sequences. + **Name the hazard, or the pin lies:** with a completion source + attached, a selected candidate shadows typed text and the minibuffer + selects candidate 0 whenever the list is non-empty (dired's + `C-x d` comment records this as S0-1/S0-4, and refuses a source for + that reason). Typing `help` and pressing RET can therefore accept a + *different* command. The pin must assert **which command ran** — not + merely that some help buffer appeared — by checking the minibuffer's + accepted value or `*help*`'s subject line before trusting the content + assertion. 5. **P — the buffer is editable and unmodified.** After greeting, `*scratch*` reports unmodified; typing a character inserts it, so step 5 works from the first frame. Targeted mutation: rendering @@ -350,15 +428,18 @@ falsified by a named mutation. seam call, leave `*scratch*` empty. **This is what makes the seam the only writer**, and it is the pin that would catch a greeting smuggled back into a constructor — including the daemon's. + *Necessary but not sufficient on its own: pin 1 is what proves the + seam is actually reached in production.* 11. **P — the step-2 pin is unchanged and still passes.** `journey_step2_launches_unconfigured_into_scratch` keeps asserting an empty status, because the welcome goes to the buffer (§2.2a). Not a new test — a stated requirement that this stage does not touch it. -**What is not pinned, stated rather than implied:** that `run()` calls -`finalize_local_launch`. `run()` takes over the terminal and no test -drives it (§3.2a). Pins 1 and 10 bracket the risk — the seam works, and -nothing else greets — but the wiring itself is reviewed, not tested. +**What is not pinned, and why it no longer matters:** `Frontend::new()` +and the event loop. Those are terminal takeover, this stage does not +touch them, and everything before them is now inside `prepare_startup` +and driven by pin 1 (§3.2b). Rev 2 left the whole local-startup wiring +untested and said so; that hole is closed rather than disclaimed. ## 5. Deferred, and why @@ -433,9 +514,11 @@ step while still falsifying a "missing entirely" grade. Branch `journey-stage1b3-welcome`, worktree `../pmacs-journey-1b3`, based on `githubsucks/main` @ `1f290d5`. Framing only; no code, no PR. -**#204 is open and touches `COHERENCE.md`, `docs/agent-handoff.md` and -`docs/active-work.md`.** This lane will conflict there; integrate late -(at PR time), never by opening a refresh PR. +**#204 has landed** (`githubsucks/main` @ `5376af1`), so the conflict +this note originally warned about is now a plain rebase surface: this +lane still touches `COHERENCE.md`, `docs/agent-handoff.md` and +`docs/active-work.md`, so integrate `main` late (at PR time) rather than +opening a refresh PR. ```sh git fetch githubsucks From 2927d2fec1faad21898001992586112ba9fdfdc0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 14:55:57 -0400 Subject: [PATCH 4/8] =?UTF-8?q?docs(journey):=20framing=20revision=204=20?= =?UTF-8?q?=E2=80=94=20close=20review=20round=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, both accepted. The pinned API was crate-private while the pin was external. `tests/journey_acceptance.rs` is a separate integration crate and cannot call a `pub(crate)` function or match a `pub(crate)` enum, so revision 3's acceptance 1 could not have compiled. `prepare_startup` and `Startup` are now `pub`, which is consistent rather than expedient: `run`, `EditorState::new`, `EditorState::open`, `install_state_dirs` and `restore_desktop_if_armed` are already public, so the startup sequence is public surface and this was the piece missing from it. The alternative — keep it private and move the pin into `src/editor.rs`'s unit tests — was rejected because §19 wants the journey row in the journey suite. The isolation that pin needs is now stated rather than left implicit. `prepare_startup` deliberately calls `install_state_dirs`, which resolves `PMACS_STATE_HOME` and XDG roots that tests cannot override (`set_var` is unsafe and forbidden), so the pin inherits the standing five-variable ambient-root requirement, asserts buffer content only, and must assert that desktop restore was unarmed — a developer whose real `init.lua` calls `desktop_mode(true)` would otherwise get a restored scratch buffer and a silently different result. The M-x acceptance still left its key assertion as an "or", and half of it was impossible: `Minibuffer::accept` does `session.take()` and resolves against the selected candidate, so after RET neither the session nor the typed contents survive to be asserted. The framing now specifies one observable, available before RET: `pmacs.minibuffer.selected()` must equal exactly "help", then accept. If the completion source selected something else the pin fails there, naming what was actually selected, instead of passing on a help buffer some other command produced. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 26 ++++-- docs/journey-stage1b3-welcome-framing.md | 103 +++++++++++++++++++---- 2 files changed, 107 insertions(+), 22 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 525ab4d..6c3971d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -347,13 +347,14 @@ If it does not, stop and repair the remote/fetch configuration. githubsucks/journey-stage1b1-compile-defaults ``` -## Journey Stage 1b-3 (P1) — FRAMING OPEN, revision 3 +## Journey Stage 1b-3 (P1) — FRAMING OPEN, revision 4 - **Branch `journey-stage1b3-welcome`**, worktree `../pmacs-journey-1b3`, based on `githubsucks/main` @ `1f290d5`. **Framing only; no code, no - PR yet.** `docs/journey-stage1b3-welcome-framing.md` revision 3, two + PR yet.** `docs/journey-stage1b3-welcome-framing.md` revision 4, three review rounds closed (round 1: four findings; round 2: two acceptance - holes plus a doc correction; all accepted). The last of the 1b split + holes plus a doc correction; round 3: a visibility mismatch and an + unobservable assertion; all accepted). The last of the 1b split (1b-1 landed #203, 1b-2 landed #204 at `5376af1`). - **What it is.** Journey step 4 / `COHERENCE.md` §18: a fresh `pmacs` greets the user with an empty buffer, an empty status line, and no @@ -374,7 +375,16 @@ If it does not, stop and repair the remote/fetch configuration. shipping no welcome, because the pins called the seam by hand. The terminal-free prefix of `run()` (everything before `Frontend::new()`) is now extracted into `prepare_startup`, which `run()` delegates to - and the pins drive. + and the pins drive. It is **`pub`**, not `pub(crate)`: the journey + suite is a separate integration crate and cannot reach crate-private + items, and `run`/`new`/`open`/`install_state_dirs`/ + `restore_desktop_if_armed` are already public, so this completes that + surface rather than widening it for a test. The pin must assert + desktop restore was **unarmed** (an ambient `init.lua` calling + `desktop_mode(true)` would otherwise change the result) and must + assert buffer content only, since `install_state_dirs` resolves real + XDG/`PMACS_STATE_HOME` roots that tests cannot override + (`set_var` is unsafe and forbidden). - **The step-2 pin is NOT amended.** Revision 1 analysed a status-line welcome and then chose `*scratch*`, but kept the amendment — an internal contradiction. The status stays empty, so the pin stays true, @@ -413,9 +423,11 @@ If it does not, stop and repair the remote/fetch configuration. programmatic API. M-x is `editor.execute-command`, a minibuffer with the `commands` completion source that calls `invoke_interactive` on accept. The `M-x help` pin dispatches the chord, enters the name and - accepts — and must assert **which** command ran, because a selected - candidate shadows typed text (dired refused a completion source for - exactly this reason). + accepts — and asserts **`pmacs.minibuffer.selected() == "help"` + BEFORE RET**, because a selected candidate shadows typed text (dired + refused a completion source for exactly this reason) and `accept()` + does `session.take()`, so nothing about the accepted value survives + afterwards. - **Integrate late.** #204 has landed at `5376af1`; this lane still touches `COHERENCE.md`, `docs/agent-handoff.md` and `docs/active-work.md`, so merge `main` at PR time rather than opening diff --git a/docs/journey-stage1b3-welcome-framing.md b/docs/journey-stage1b3-welcome-framing.md index f62c570..4b234b0 100644 --- a/docs/journey-stage1b3-welcome-framing.md +++ b/docs/journey-stage1b3-welcome-framing.md @@ -1,11 +1,30 @@ # Journey Stage 1b-3 — say something when the editor opens -**Status: framing, rev 3 — awaiting review round 3.** +**Status: framing, rev 4 — awaiting review round 4.** **Serves `COHERENCE.md` §2 (the golden journey, step 4), §18 (onboarding), §19, §20 Priority 1.** ## 0. Revision history +- rev 4 (2026-07-31) — review round 3. Two findings, both accepted. + - **The pinned API was crate-private but the pin was external.** + `tests/journey_acceptance.rs` is a separate integration crate and + cannot call `pub(crate) fn prepare_startup` or match a + `pub(crate) enum Startup`, so rev 3's acceptance 1 was + uncompilable. Resolved by making both **`pub`** — which is + consistent with the startup surface already exported (`run`, + `new`, `open`, `install_state_dirs`, `restore_desktop_if_armed` + are all `pub`), not a widening invented for a test. §3.2b also now + states the **isolation** the pin needs, since `prepare_startup` + deliberately calls `install_state_dirs` and can reach desktop + restore. + - **The M-x pin still had an "or", and half of it was impossible.** + `Minibuffer::accept` does `self.session.take()` and resolves + against the *selected candidate* (`src/minibuffer.rs:334-337`), so + after RET neither the session nor the typed contents survive to be + asserted. §4.4 now specifies **one** observable, available before + RET: `pmacs.minibuffer.selected()` + (`src/lua_bindings/mod.rs:14030`) must equal exactly `"help"`. - rev 3 (2026-07-31) — review round 2. Two acceptance holes and one doc correction; all accepted. - **The real startup wiring was still unpinned, and rev 2 knew it.** @@ -311,9 +330,9 @@ prefix becomes a helper: /// /// Extracted so the local-startup sequence is testable. `run` adds only /// `Frontend::new()` and the event loop. -pub(crate) fn prepare_startup(file: Option) -> io::Result; +pub fn prepare_startup(file: Option) -> io::Result; -pub(crate) enum Startup { +pub enum Startup { /// Ready to enter the local TUI loop — desktop restored, launch /// finalized. Local(EditorState), @@ -323,6 +342,38 @@ pub(crate) enum Startup { } ``` +**`pub`, not `pub(crate)`.** The journey ratchet lives in +`tests/journey_acceptance.rs`, a separate integration crate that cannot +reach crate-private items — rev 3 specified `pub(crate)` and an external +pin, which does not compile. Exporting it is also consistent rather than +expedient: `run`, `EditorState::new`, `EditorState::open`, +`install_state_dirs` and `restore_desktop_if_armed` are **already** +`pub`, so the startup sequence is public surface and this is the piece +that was missing from it. (The alternative — keep it private and move +the pin into `src/editor.rs`'s unit tests — was rejected because §19 +wants the journey row in the journey suite.) + +**Isolation the pin requires, stated because `prepare_startup` reaches +real state.** It calls `install_state_dirs`, which resolves +`user_history_dir()` / `user_state_dir()` from `PMACS_STATE_HOME` and +XDG (`src/state.rs:54`). Tests cannot set those themselves — +`std::env::set_var` is `unsafe` and this crate forbids it — so the pin +inherits the same ambient-root requirement as every other +editor-constructing test: the five bootstrap-storage variables +controlled externally, which is already the standing local-gate +workaround and is what #201's framing owns fixing. + +Two consequences the pin must respect: + +- **Assert buffer content only.** No assertion may depend on a state + directory being present, absent, or writable, so an ambient root + cannot change the verdict. +- **Assert that desktop restore is unarmed**, rather than assuming it. + `restore_desktop_if_armed` is gated on `DesktopRestoreArmed`, which + only `pmacs.session.desktop_mode(true)` sets from config. A developer + whose real `init.lua` arms desktop mode would otherwise get a restored + `*scratch*` and a silently different result. + `run()` becomes `match prepare_startup(file)? { … }` plus the loop it already has. The attach arms keep calling `run_attach*` from `run()`, because those take over the terminal. @@ -366,9 +417,14 @@ belong to §20 Priority 4's discovery arc (§5). falsified by a named mutation. 1. **N — journey step 4, through the production startup path.** - Drive **`prepare_startup(None)`** (§3.2b) — the same call `run()` - makes — and assert the returned `Startup::Local` state has `*scratch*` - active, **non-empty**, naming `M-x`. This is the ratchet row. + In `tests/journey_acceptance.rs`, drive the **public** + `prepare_startup(None)` (§3.2b) — the same call `run()` makes — and + assert the returned `Startup::Local` state has `*scratch*` active, + **non-empty**, naming `M-x`. This is the ratchet row. + *Preconditions it must assert rather than assume* (§3.2b): that + desktop restore was **unarmed**, so an ambient `init.lua` calling + `desktop_mode(true)` cannot silently change the result. It asserts + buffer content only — never anything about state directories. *Rev 1 claimed `EditorState::new()` was the entry point (§2.2 shows it is shared with `open()` and the daemon); rev 2 then called the seam by hand, which left the wiring unpinned (§3.2b). This drives neither @@ -393,15 +449,32 @@ falsified by a named mutation. on accept (`builtin/commands/default.lua:743-755`). So the pin **dispatches `M-x` as a key**, enters `help`, and accepts — then asserts `*help*` contains the entries' key sequences. - **Name the hazard, or the pin lies:** with a completion source - attached, a selected candidate shadows typed text and the minibuffer - selects candidate 0 whenever the list is non-empty (dired's - `C-x d` comment records this as S0-1/S0-4, and refuses a source for - that reason). Typing `help` and pressing RET can therefore accept a - *different* command. The pin must assert **which command ran** — not - merely that some help buffer appeared — by checking the minibuffer's - accepted value or `*help*`'s subject line before trusting the content - assertion. + **One observable, chosen here rather than left to implementation.** + With a completion source attached, a selected candidate shadows typed + text and the minibuffer selects candidate 0 whenever the list is + non-empty (dired's `C-x d` comment records this as S0-1/S0-4 and + refuses a source for that reason), so typing `help` and pressing RET + can accept a *different* command. + + The accepted value cannot be recovered afterwards: + `Minibuffer::accept` does `self.session.take()` and resolves against + the selected candidate (`src/minibuffer.rs:334-337`), so after RET + neither the session nor the contents survive. + + So the assertion happens **before** RET: + + ``` + dispatch M-x → type "help" + assert pmacs.minibuffer.selected() == "help" -- exact, no fallback + dispatch RET + assert *help* contains every entry's `keys` + ``` + + `pmacs.minibuffer.selected()` returns the selected candidate string + (`src/lua_bindings/mod.rs:14030`). If the completion source has + selected something else, the pin fails **there**, naming what was + actually selected — instead of passing on a help buffer some other + command produced. 5. **P — the buffer is editable and unmodified.** After greeting, `*scratch*` reports unmodified; typing a character inserts it, so step 5 works from the first frame. Targeted mutation: rendering From df500b115b95ceb975cd7f9a2d7ea2a2b3ade901 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 15:54:05 -0400 Subject: [PATCH 5/8] =?UTF-8?q?feat(welcome):=20greet=20an=20unconfigured?= =?UTF-8?q?=20launch=20=E2=80=94=20journey=20step=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `docs/journey-stage1b3-welcome-framing.md` (approved at revision 4, after three review rounds). The last of the 1b split. `COHERENCE.md` §18 graded onboarding "missing entirely": no welcome, no cheat sheet reachable from inside the editor, and `M-x` — the only door in — discoverable only by already knowing about it. A fresh `pmacs` now greets an untouched `*scratch*` with three lines naming `M-x` and four real bindings, and `M-x help` renders a cheat sheet. The startup seam is the substance. No constructor is the right hook: `EditorState::open` calls `new` before resolving its target, the daemon constructs one too, `init.lua` runs inside `new`, and desktop restore happens later still. So `run()`'s terminal-free prefix is extracted into `prepare_startup`, which `run` delegates to, and the greeting happens there — after config, after attach dispatch resolves to local, and after desktop restore. Extracting it is also what makes the wiring testable: with the greeting called by hand from tests instead, deleting the production call would leave every assertion green while shipping no welcome. Lua owns what is said, Rust owns when and where. `pmacs.welcome.entries` is a structured list that both renders the text and drives the binding checks — scraping the rendered prose would be ambiguous, since `C-c c` is two chords and nothing in the text marks the boundary. The greeting is deliberately NOT written through `set_generated_contents`: that would lift read-only, discard history and mark the buffer generated, all wrong for the buffer journey step 5 requires the user to type into immediately. It is left unmodified so it does not look like unsaved work. `M-x help` renders through `editor.describe-command`'s existing `*help*` mechanism via a new `pmacs.editor._show_help` seam, rather than growing a second help surface. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- builtin/commands/default.lua | 15 ++ builtin/runtime/welcome.lua | 102 ++++++++++++ src/editor.rs | 186 +++++++++++++++++----- tests/journey_acceptance.rs | 296 +++++++++++++++++++++++++++++++++++ 4 files changed, 560 insertions(+), 39 deletions(-) create mode 100644 builtin/runtime/welcome.lua diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 2a13c21..48d6384 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -1259,6 +1259,21 @@ local function show_help_text(text) pmacs.window.switch_buffer(buf) end +--- Internal seam (journey Stage 1b-3): render `text` into the shared +--- `*help*` buffer. Exposed under the underscore convention so +--- `runtime/welcome.lua`'s `M-x help` renders through THIS mechanism +--- rather than growing a second help surface — `commands/default.lua` +--- loads before the runtime chunks, so the seam is present by then. +--- +--- Inherits this mechanism's two known gaps, recorded rather than +--- papered over: it writes with `delete`/`insert` instead of +--- `set_generated_contents` (so the buffer stays ordinarily editable and +--- keeps its undo history), and it finds `*help*` BY NAME, so a foreign +--- buffer of that name would be cleared. +function pmacs.editor._show_help(text) + show_help_text(text) +end + cmd { name = "editor.describe-command", description = "Prompt for a command name and render its description in *help*.", fn = function() diff --git a/builtin/runtime/welcome.lua b/builtin/runtime/welcome.lua new file mode 100644 index 0000000..fa55b3c --- /dev/null +++ b/builtin/runtime/welcome.lua @@ -0,0 +1,102 @@ +-- welcome.lua --- journey step 4: say something when the editor opens. +-- Framing: docs/journey-stage1b3-welcome-framing.md. +-- +-- `COHERENCE.md` §18 graded onboarding "missing entirely": no welcome, +-- no tutorial, no cheat sheet reachable from inside the editor. The sole +-- discovery affordance was knowing to press `M-x`. +-- +-- Split of responsibility with Rust: this file owns WHAT is said (the +-- entries and their rendering) and the `help` command; the Rust seam +-- `EditorState::finalize_local_launch` owns WHEN and WHERE — it alone +-- decides that this is a local, no-target launch whose `*scratch*` is +-- still untouched, and it clears the modified flag afterwards (there is +-- no Lua API for that, deliberately). + +pmacs.welcome = pmacs.welcome or {} + +--- The keys the welcome advertises, in display order. +--- +--- `keys` is EXACTLY what `pmacs.keymap.lookup` accepts, which is the +--- whole point of the shape: the acceptance suite checks every entry +--- resolves, so the welcome can never advertise a binding a later stage +--- removed. Scraping the rendered prose instead would be ambiguous — +--- `C-c c` is two chords and nothing in the text marks the boundary. +--- +--- Public so a user who rebinds can rebuild it from `init.lua`. +pmacs.welcome.entries = { + { keys = "C-x C-f", label = "open a file" }, + { keys = "C-c t", label = "terminal" }, + { keys = "C-c c", label = "build" }, + { keys = "C-x b", label = "switch buffer" }, +} + +-- Two entries per line, padded so the labels align. Kept deliberately +-- small: three lines total, because the greeting a user must delete +-- before typing should not be chrome. +local function entry_columns() + local width = 0 + for _, e in ipairs(pmacs.welcome.entries) do + if #e.keys > width then width = #e.keys end + end + local lines, pending = {}, nil + for _, e in ipairs(pmacs.welcome.entries) do + local cell = string.format("%-" .. width .. "s %s", e.keys, e.label) + if pending then + lines[#lines + 1] = " " .. string.format("%-24s", pending) .. cell + pending = nil + else + pending = cell + end + end + if pending then lines[#lines + 1] = " " .. pending end + return lines +end + +--- The welcome text, as written into an untouched `*scratch*`. +--- +--- `M-x` and `M-x help` are prose rather than entries: `M-x` is the +--- palette itself and `help` is a command name, so neither is a keymap +--- lookup. The acceptance checks the command exists instead. +function pmacs.welcome.text() + local lines = { "Welcome to pmacs. M-x runs any command; M-x help lists the keys." } + for _, line in ipairs(entry_columns()) do + lines[#lines + 1] = line + end + return table.concat(lines, "\n") .. "\n" +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, +} diff --git a/src/editor.rs b/src/editor.rs index 4be27be..12e755b 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -705,6 +705,17 @@ impl EditorState { include_str!("../builtin/runtime/compile.lua"), ) .expect("load compile builtin chunk"); + // Journey Stage 1b-3: the welcome text and `M-x help`. Loaded + // after `commands/default.lua` (which `attach_editor` above ran) + // so `pmacs.editor._show_help` exists, and after the runtime + // chunks whose keys it advertises, so a binding it names is + // already registered when the acceptance suite checks them. + lua_host + .eval( + Some("@pmacs/builtin/runtime/welcome.lua"), + include_str!("../builtin/runtime/welcome.lua"), + ) + .expect("load welcome 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 @@ -878,6 +889,73 @@ impl EditorState { } } + /// Final step of a **local, no-target** launch: greet an untouched + /// `*scratch*` (journey step 4, `COHERENCE.md` §18). + /// + /// Called from [`prepare_startup`] after config has run, after + /// attach dispatch resolved to local, and after + /// [`Self::restore_desktop_if_armed`]. None of the constructors is + /// the right hook: `EditorState::open` calls `new` *before* + /// resolving its target, the daemon constructs one too, and + /// `init.lua` runs inside `new` — so a greeting written there would + /// reach a daemon session, precede the file argument that replaces + /// the buffer, and outrun anything config or a restored desktop puts + /// in `*scratch*`. + /// + /// Greets only when all four hold; each excludes one of those cases: + /// + /// 1. `had_file` is false — a positional argument means "open this". + /// 2. the session is local — guaranteed by the call site. + /// 3. `*scratch*` is the active buffer — restore may have moved it. + /// 4. `*scratch*` is empty — never overwrite config or a restore. + /// + /// Leaves the buffer **unmodified**: the greeting must not look like + /// unsaved work. It is deliberately *not* written through + /// `set_generated_contents`, which would lift read-only, discard + /// history and mark the buffer generated — all wrong for a buffer + /// journey step 5 requires the user to type into immediately. + pub fn finalize_local_launch(&mut self, had_file: bool) { + if had_file { + return; + } + let buffer_id = self.core.borrow().active_buffer_id(); + { + let registry = self.core.borrow().registry.clone(); + let reg = registry.borrow(); + let Ok(buf) = reg.get(buffer_id) else { + return; + }; + if buf.name() != "*scratch*" || !buf.is_empty() { + return; + } + } + let text: String = match self + .lua_host + .lua() + .load("return pmacs.welcome.text()") + .eval() + { + Ok(text) => text, + // A user who replaced `pmacs.welcome` with something broken + // gets no greeting, not a failed launch. + Err(_) => return, + }; + let registry = self.core.borrow().registry.clone(); + let mut reg = registry.borrow_mut(); + let Ok(buf) = reg.get_mut(buffer_id) else { + return; + }; + if buf + .apply_edit(crate::buffer::EditOp::Insert { + pos: 0, + bytes: text.as_bytes(), + }) + .is_ok() + { + buf.mark_clean(); + } + } + /// Restore the session saved under this desktop's key, if armed /// (`pmacs.session.desktop_mode(true)` called it) and no positional /// file arg was given (Q#DS7). Called from the `RunLocal` arm of @@ -3590,6 +3668,68 @@ impl Default for EditorState { // Run loop // --------------------------------------------------------------------------- +/// Outcome of [`prepare_startup`]: either a session ready for the local +/// TUI loop, or a hand-off the caller must perform. +pub enum Startup { + /// Ready to enter the local loop — desktop restored, launch + /// finalized. + Local(Box), + /// The init-time attach request resolved to something other than + /// local. Performing it takes over the terminal, so it stays out of + /// [`prepare_startup`] and is the caller's job. + HandOff(crate::attach_dispatch::AttachDispatch), +} + +/// Everything [`run`] does **before** it touches the terminal: +/// construct from the target, install state dirs, dispatch the +/// init-time attach request, and — on the local path — restore the +/// desktop and finalize the launch. +/// +/// Extracted so the local-startup sequence is testable: `run` adds only +/// `Frontend::new()` and the event loop, which is where terminal +/// takeover genuinely lives. Without this split, deleting the +/// [`EditorState::finalize_local_launch`] call would leave every +/// direct-call test green while shipping no welcome at all. +/// +/// `pub` rather than `pub(crate)` because the journey acceptance suite +/// is a separate integration crate — and because the rest of this +/// sequence (`run`, `EditorState::new`, `EditorState::open`, +/// `install_state_dirs`, `restore_desktop_if_armed`) is already public, +/// so this completes that surface rather than widening it. +pub fn prepare_startup(file: Option) -> io::Result { + // Capture before the `match` consumes `file`: a positional file arg + // means "open this", not "restore my desktop" (Q#DS7). + let had_file = file.is_some(); + let mut state = match file { + Some(path) => EditorState::open(path)?, + None => EditorState::new(), + }; + // Real session: wire up on-disk persistence (history + pmacs.state). + state.install_state_dirs(); + // Post-init dispatch: read whatever init.lua left in the + // RequestedAttach slot and decide whether to run local or hand off + // to attach mode. `take_requested_attach` consumes the slot. + let requested = state.lua_host.take_requested_attach(); + match crate::attach_dispatch::dispatch_attach(requested) { + crate::attach_dispatch::AttachDispatch::RunLocal => { + // Committed to local mode: restore the desktop if armed and + // no file arg was given (Q#DS7). Done here, not right after + // construction, so a hand-off never populates an + // EditorState it's about to drop. + state.restore_desktop_if_armed(had_file); + // Journey step 4: the last thing before the loop, so config + // and any restored desktop have already had their say. + state.finalize_local_launch(had_file); + Ok(Startup::Local(Box::new(state))) + } + // The EditorState is dropped by the caller before it takes over + // the terminal: attach mode constructs its own Frontend, and a + // locally-built one would leak its alternate-screen / raw-mode + // setup if held across the call. + other => Ok(Startup::HandOff(other)), + } +} + /// Main run loop. Opens the file (if any), takes over the terminal, /// renders, dispatches keys, until the user quits. /// @@ -3611,54 +3751,22 @@ impl Default for EditorState { /// local-TUI for the terminal. pub fn run(file: Option) -> io::Result<()> { install_panic_hook(); - // Capture before the `match` consumes `file`: a positional file arg - // means "open this", not "restore my desktop" (Q#DS7). - let had_file = file.is_some(); - let mut state = match file { - Some(path) => EditorState::open(path)?, - None => EditorState::new(), - }; - // Real session: wire up on-disk persistence (history + pmacs.state). - state.install_state_dirs(); - - // Post-init dispatch: read whatever init.lua left in the - // RequestedAttach slot and decide whether to run local or hand - // off to attach mode. `take_requested_attach` consumes the slot - // — even on the hand-off path the local EditorState is dropped - // before attach::run_attach takes over the terminal, so the - // request is consumed exactly once. - let requested = state.lua_host.take_requested_attach(); - match crate::attach_dispatch::dispatch_attach(requested) { - crate::attach_dispatch::AttachDispatch::RunLocal => { - // Committed to local mode: restore the desktop if armed and - // no file arg was given (Q#DS7). Done here, not right after - // construction, so a hand-off to attach mode (above) never - // populates an EditorState it's about to drop. - state.restore_desktop_if_armed(had_file); - // Fall through to the local TUI loop below. - } - crate::attach_dispatch::AttachDispatch::RunAttachLocalSocket(socket) => { - // Drop the local EditorState before taking over the - // terminal: attach mode constructs its own Frontend, and - // the locally-built one would leak its alternate-screen - // / raw-mode setup if held across the call. - drop(state); + let mut state = match prepare_startup(file)? { + Startup::Local(state) => *state, + Startup::HandOff(crate::attach_dispatch::AttachDispatch::RunAttachLocalSocket(socket)) => { return crate::attach::run_attach(socket).map_err(|e| io::Error::other(format!("{e}"))); } - crate::attach_dispatch::AttachDispatch::RunAttachSsh(target) => { - // Same EditorState-drop reasoning as the local-socket - // path: SSH attach takes over the terminal. - drop(state); + Startup::HandOff(crate::attach_dispatch::AttachDispatch::RunAttachSsh(target)) => { return crate::attach::run_attach_ssh(target) .map_err(|e| io::Error::other(format!("{e}"))); } - dispatch @ crate::attach_dispatch::AttachDispatch::DeferredInV01 { .. } => { + Startup::HandOff(dispatch) => { let msg = dispatch .deferred_message() - .expect("DeferredInV01 always has a message"); + .unwrap_or_else(|| "unsupported attach dispatch".to_owned()); return Err(io::Error::other(msg)); } - } + }; let mut frontend = Frontend::new()?; let mut render_state = crate::instance_render::RenderState::new(frontend.size()); diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs index 74cd265..42c25dd 100644 --- a/tests/journey_acceptance.rs +++ b/tests/journey_acceptance.rs @@ -1742,3 +1742,299 @@ fn journey_step9_the_compile_directory_is_detection_canonical() { "the header must name the directory detection resolved to;\n{text}" ); } + +// --------------------------------------------------------------------------- +// Step 4 — understand the visible interface (Journey Stage 1b-3) +// +// `COHERENCE.md` §18 graded onboarding "missing entirely": no welcome, +// no cheat sheet reachable from inside the editor, and `M-x` the only +// door in — which a new user has no way to learn about. +// +// These rows drive `prepare_startup`, the production call `run()` makes. +// Calling `finalize_local_launch` by hand instead would leave the wiring +// unpinned: deleting the one call inside `prepare_startup` would keep +// every other assertion here green while shipping no welcome at all. +// --------------------------------------------------------------------------- + +/// The `*scratch*` buffer's text, wherever it currently sits. +fn scratch_text(s: &EditorState) -> String { + eval( + s, + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == "*scratch*" then + return id:slice(0, id:len()) + end + end + return "" + "#, + ) +} + +fn welcome_entries(s: &EditorState) -> Vec<(String, String)> { + let raw: Vec = eval( + s, + "local out = {} + for _, e in ipairs(pmacs.welcome.entries) do + out[#out + 1] = e.keys .. '\\1' .. e.label + end + return out", + ); + raw.into_iter() + .map(|row| { + let (keys, label) = row + .split_once('\u{1}') + .expect("entry encodes keys and label"); + (keys.to_owned(), label.to_owned()) + }) + .collect() +} + +/// Drive the production startup path with no target, as `pmacs` does. +fn start_local() -> EditorState { + match pmacs::editor::prepare_startup(None).expect("startup must not fail") { + pmacs::editor::Startup::Local(state) => *state, + pmacs::editor::Startup::HandOff(_) => panic!("no init.lua attach request in a test"), + } +} + +/// **N** — journey step 4: a no-target local launch greets. +/// +/// Falsified by deleting the `finalize_local_launch` call inside +/// `prepare_startup` — the mutation every by-hand pin would survive. +#[test] +fn journey_step4_a_no_target_launch_greets_in_scratch() { + let s = start_local(); + + // Preconditions asserted, not assumed (framing §3.2b): a developer + // whose real init.lua arms desktop mode would otherwise get a + // restored scratch and a silently different result. + assert!( + eval::( + &s, + "return pmacs.session == nil or pmacs.session.desktop_armed ~= true" + ), + "precondition: desktop restore must be unarmed for this pin to mean anything" + ); + assert_eq!(active_name(&s), "*scratch*"); + + let text = active_text(&s); + assert!( + !text.is_empty(), + "an unconfigured launch must say something" + ); + assert!( + text.contains("M-x"), + "and must name the one key that opens everything; got {text:?}" + ); +} + +/// **N** — every entry the welcome names is actually bound. +/// +/// A property over the structured list, not a scrape of prose: `C-c c` +/// is two chords and nothing in the rendered text marks the boundary. +#[test] +fn journey_step4_every_advertised_key_is_bound() { + let s = start_local(); + let entries = welcome_entries(&s); + assert!( + !entries.is_empty(), + "precondition: the entry list must be non-empty or this loop is vacuous" + ); + for (keys, label) in entries { + let bound: Option = eval( + &s, + &format!("local b = pmacs.keymap.lookup({keys:?}) return b and b.command"), + ); + assert!( + bound.is_some(), + "the welcome advertises {keys:?} ({label}) but nothing is bound to it" + ); + } +} + +/// **N** — the rendered text contains every entry. +/// +/// Pin 2 alone would pass if rendering silently dropped one. +#[test] +fn journey_step4_the_rendered_welcome_contains_every_entry() { + let s = start_local(); + let text = active_text(&s); + for (keys, label) in welcome_entries(&s) { + assert!( + text.contains(&keys), + "welcome text omits the key {keys:?}; got {text:?}" + ); + assert!( + text.contains(&label), + "welcome text omits the label {label:?}; got {text:?}" + ); + } +} + +/// **N** — `M-x help` renders the cheat sheet, reached the way a user +/// reaches it. +/// +/// `pmacs.command.invoke` is the *programmatic* API; M-x is +/// `editor.execute-command`, a minibuffer with the `commands` completion +/// source that calls `invoke_interactive` only on accept. +/// +/// The selection is asserted **before** RET: a selected candidate +/// shadows typed text, and `Minibuffer::accept` does `session.take()`, +/// so nothing about the accepted value survives afterwards. +#[test] +fn journey_step4_m_x_help_renders_the_cheat_sheet() { + let mut s = start_local(); + + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('x'), KeyModifiers::ALT), + ); + assert!( + eval::(&s, "return pmacs.minibuffer.is_active()"), + "M-x must open the command palette" + ); + for ch in "help".chars() { + type_char(&mut s, ch); + } + assert_eq!( + eval::>(&s, "return pmacs.minibuffer.selected()").as_deref(), + Some("help"), + "the completion source must have `help` selected; a different \ + candidate would accept a different command" + ); + + press(&mut s, KeyCode::Enter); + pump(&mut s); + + let help = named_text(&s, "*help*"); + for (keys, _) in welcome_entries(&s) { + assert!( + help.contains(&keys), + "the cheat sheet omits {keys:?}; got:\n{help}" + ); + } +} + +/// **P** — the greeted buffer is editable and unmodified. +/// +/// Targeted mutation: rendering through `set_generated_contents`, which +/// would lift read-only, discard history, and fail the insert. +#[test] +fn journey_step4_preservation_the_greeted_scratch_is_editable_and_clean() { + let mut s = start_local(); + assert!( + eval::( + &s, + "return pmacs.describe.buffer(pmacs.window.buffer()).modified == false" + ), + "a greeting must not look like unsaved work" + ); + + let before = active_text(&s).len(); + type_char(&mut s, 'X'); + assert!( + active_text(&s).len() > before, + "step 5 must still work from the first frame: typing inserts" + ); +} + +/// **P** — a file target does not greet. +#[test] +fn journey_step4_preservation_a_file_target_does_not_greet() { + let td = project(); + let path = td.path().join("alpha.txt"); + let s = match pmacs::editor::prepare_startup(Some(path.clone())).expect("startup") { + pmacs::editor::Startup::Local(state) => *state, + pmacs::editor::Startup::HandOff(_) => panic!("no attach request"), + }; + assert_eq!(active_name(&s), path.display().to_string()); + assert_eq!( + scratch_text(&s), + "", + "a positional argument means \"open this\", not \"greet me\"" + ); +} + +/// **P** — a directory target does not greet either, so Stage 1a's +/// dired listing is what the user sees. +/// +/// Separate from the file pin because the directory path reaches +/// `*scratch*` differently: the bootstrap replaces the window's buffer +/// and `replace_active_buffer` removes nothing, so the scratch buffer +/// still exists to be wrongly greeted. +#[test] +fn journey_step4_preservation_a_directory_target_does_not_greet() { + let td = project(); + let mut s = + match pmacs::editor::prepare_startup(Some(td.path().to_path_buf())).expect("startup") { + pmacs::editor::Startup::Local(state) => *state, + pmacs::editor::Startup::HandOff(_) => panic!("no attach request"), + }; + pump(&mut s); + assert!(active_name(&s).starts_with("*dired:")); + assert_eq!(scratch_text(&s), "", "the dired listing is the greeting"); +} + +/// **P** — a non-empty `*scratch*` is never overwritten. +#[test] +fn journey_step4_preservation_existing_scratch_content_survives() { + let mut s = EditorState::new(); + exec(&s, "pmacs.window.buffer():insert(0, 'user content')"); + s.finalize_local_launch(false); + assert_eq!( + active_text(&s), + "user content", + "config or a restored desktop owns whatever is already there" + ); +} + +/// **P** — a non-active `*scratch*` is not greeted. Stands in for a +/// desktop restore having put something else in front. +#[test] +fn journey_step4_preservation_a_backgrounded_scratch_is_not_greeted() { + let td = project(); + let mut s = EditorState::new(); + exec( + &s, + &format!( + "pmacs.window.display_file({:?})", + td.path().join("alpha.txt").display().to_string() + ), + ); + pump(&mut s); + s.finalize_local_launch(false); + assert_eq!( + scratch_text(&s), + "", + "only the buffer actually greeting the user is written to" + ); +} + +/// **P** — the constructors greet nothing on their own. +/// +/// This is what makes the seam the only writer, and would catch a +/// greeting smuggled back into a constructor — including the daemon's. +/// Necessary but not sufficient: pin 1 is what proves the seam is +/// reached in production. +#[test] +fn journey_step4_preservation_constructors_never_greet() { + let bare = EditorState::new(); + assert_eq!(scratch_text(&bare), "", "EditorState::new must not greet"); + + let td = project(); + let file = EditorState::open(td.path().join("alpha.txt")).expect("open file"); + assert_eq!( + scratch_text(&file), + "", + "EditorState::open(file) must not greet" + ); + + let mut dir = EditorState::open(td.path().to_path_buf()).expect("open dir"); + pump(&mut dir); + assert_eq!( + scratch_text(&dir), + "", + "EditorState::open(dir) must not greet" + ); +} From 94036774a8ddda4120b5b9b40f2430c9b67aa50a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 16:43:02 -0400 Subject: [PATCH 6/8] fix(welcome): notify the core after writing scratch; unstale the ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 on #205, two findings, both accepted. The greeting was written straight into the registry without calling `notify_buffer_edit`. The window's `TextView` had been indexed while `*scratch*` was empty, and newlines are zero-width to a painter working from a stale line index — so the first TUI frame collapsed the whole three-line greeting onto row 0. Every buffer-text assertion passed because the buffer content was correct; only the rendering was wrong. The edit is now captured, the registry borrow released, and the core notified. The pin that would have caught it paints a real frame and asserts the second line occupies its own row AND that row 0 does not contain it — both directions, because a one-direction check passes when everything collapses upward. Bitten by dropping the notify call: row 1 comes back empty with row 0 holding the lot, and it is the only pin that fails. Second: the project docs still described the arc as it was two PRs ago. `COHERENCE.md` §20 called 1b-2 in flight and the welcome buffer unstarted; its arc list said 1b-3 remained; and the ledger's journey lane header still read "1b-2 PR OPEN" while the 1b-3 block carried a mangled "Framing only; no code" line left by an earlier edit. All now describe the PR-head state per §25. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- COHERENCE.md | 22 ++++++------ docs/active-work.md | 11 +++--- src/editor.rs | 29 ++++++++++------ tests/journey_acceptance.rs | 68 +++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 25 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 5e723e4..43dd0ec 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -1575,19 +1575,20 @@ Establish the end-to-end workflow; treat regressions as release blockers. **State: runs to step 5; thin from step 6 (§2). Mostly wiring, and unusually cheap:** directory-argument handling (**done**: Journey Stage 1a); a find-file surface (**done**: #162 open-by-path, #165 -browsing); surfacing the LSP spawn failure with guidance (**in flight**: -Journey Stage 1b-2, §1.2); a compile keybinding + `cargo build`/`test` -default (**done**: Journey Stage 1b-1, #203, from the existing +browsing); surfacing the LSP spawn failure with guidance (**done**: Journey Stage +1b-2, #204, §1.2); a compile keybinding + `cargo build`/`test` default +(**done**: Journey Stage 1b-1, #203, from the existing `ProjectKind::Rust` — **not** `Cargo`, see §24); a terminal keybinding -(**done**: `C-c t`, #173); a welcome buffer. The journey acceptance +(**done**: `C-c t`, #173); a welcome buffer (**in flight**: Journey +Stage 1b-3). The journey acceptance suite (§19) is the ratchet that keeps it fixed — it **exists now** (`tests/journey_acceptance.rs`, Stage 1a), seeded with steps 2, 3 and 5, and carrying step 9 since #203. -Journey Stage 1b is the named remainder, and it splits: **1b-1 — the -compile binding + project-kind defaults — landed as #203**; **1b-2** -(LSP spawn guidance, step 6) is in flight; **1b-3**, the welcome buffer -(step 4), remains. +Journey Stage 1b is the named remainder, and it splits: **1b-1** (the +compile binding + project-kind defaults) landed as #203 and **1b-2** +(LSP spawn guidance, step 6) as #204; **1b-3**, the welcome buffer +(step 4), is in flight and completes the split. ### Priority 2: Make workspace and location explicit @@ -1660,8 +1661,9 @@ implementation — this list is direction, not commitment): #165's dired buffer rather than growing a second directory surface. **Stage 1b-1 — landed (#203)**: the compile binding and project-kind defaults, with the prompt capturing its directory rather than - re-resolving it at accept time. **Stage 1b-2 — in flight**: - LSP-failure surfacing. **Stage 1b-3 — remaining**: welcome buffer. + re-resolving it at accept time. **Stage 1b-2 — landed (#204)**: + LSP-failure surfacing. **Stage 1b-3 — in flight**: the welcome + buffer and `M-x help`. With it the 1b split is complete. 2. **Discovery surface** (P4): the describe/list/where-is command family, M-x rich rows, help unification, help prefix. 3. **Transient keymap layer** (§6): the overlay scope + lifetime diff --git a/docs/active-work.md b/docs/active-work.md index 315422f..1871bc6 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -255,11 +255,11 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Journey lane (P1) — 1a and 1b-1 MERGED; 1b-2 PR OPEN; 1b-3 REMAINS +## Journey lane (P1) — 1a, 1b-1, 1b-2 MERGED; 1b-3 PR OPEN -**Rewritten, not removed, at #203's merge.** Rule 4 removes a lane when -its ARC is done; the journey arc is not — 1b-2 is in flight and 1b-3 is -unframed. Stage 1a (#182/#183) and Stage 1b-1 (#203) are on `main` and +**Rewritten, not removed.** Rule 4 removes a lane when its ARC is done; +the journey arc is not — 1b-3 is the last stage of the 1b split and is +still open. Stage 1a (#182/#183) and Stage 1b-1 (#203) are on `main` and their durable facts are in `docs/agent-handoff.md` §1, which is rule 4's precondition satisfied rather than deferred. @@ -326,7 +326,8 @@ re-conflict on every merge. ## Journey Stage 1b-3 (P1) — IMPLEMENTED, PR OPEN - **Branch `journey-stage1b3-welcome`**, worktree `../pmacs-journey-1b3`, - based on `githubsucks/main` @ `1f290d5`. **Framing only; no code, no + based on `githubsucks/main` @ `1f290d5`, **integrated with `main` @ + `5376af1`** (#204). **Implemented; PR #205 open.** `docs/journey-stage1b3-welcome-framing.md` revision 4, three review rounds closed (round 1: four findings; round 2: two acceptance holes plus a doc correction; round 3: a visibility mismatch and an diff --git a/src/editor.rs b/src/editor.rs index 12e755b..b089973 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -940,20 +940,29 @@ impl EditorState { // gets no greeting, not a failed launch. Err(_) => return, }; + // Capture the edit and RELEASE the registry borrow before + // notifying: `notify_buffer_edit` borrows the registry itself. let registry = self.core.borrow().registry.clone(); - let mut reg = registry.borrow_mut(); - let Ok(buf) = reg.get_mut(buffer_id) else { - return; - }; - if buf - .apply_edit(crate::buffer::EditOp::Insert { + let edit = { + let mut reg = registry.borrow_mut(); + let Ok(buf) = reg.get_mut(buffer_id) else { + return; + }; + let Ok(edit) = buf.apply_edit(crate::buffer::EditOp::Insert { pos: 0, bytes: text.as_bytes(), - }) - .is_ok() - { + }) else { + return; + }; buf.mark_clean(); - } + edit + }; + // Without this the greeting renders as ONE row on the first + // frame: the window's `TextView` indexed `*scratch*` while it + // was empty, and newlines are zero-width to a painter working + // from a stale line index. Every other direct-registry writer + // notifies for the same reason. + self.core.borrow_mut().notify_buffer_edit(buffer_id, &edit); } /// Restore the session saved under this desktop's key, if armed diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs index 4dd732d..db88942 100644 --- a/tests/journey_acceptance.rs +++ b/tests/journey_acceptance.rs @@ -2124,3 +2124,71 @@ fn journey_step6_a_missing_language_server_is_reported_not_swallowed() { is what made highlighting able to mask this" ); } + +/// **N** — the greeting renders as separate rows on the **first frame**. +/// +/// Writing to the registry without `notify_buffer_edit` leaves the +/// window's `TextView` indexed against the empty `*scratch*` it was +/// built for. Newlines are zero-width to a painter working from a stale +/// line index, so the whole three-line greeting collapses onto row 0 — +/// visible to a user, invisible to every buffer-text assertion above. +/// +/// Falsified by dropping the `notify_buffer_edit` call in +/// `finalize_local_launch`. +#[test] +fn journey_step4_the_welcome_paints_as_multiple_rows_on_the_first_frame() { + use pmacs::cell::{Cell, CellGrid, CellSize, Glyph}; + + let s = start_local(); + let (rows, cols) = (12u32, 100u32); + let mut cells = vec![Cell::default(); (rows * cols) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: cols, + size: CellSize::new(rows, cols), + }; + let _ = pmacs::editor::paint_frame( + &s, + FrontendId::LOCAL, + &std::collections::HashMap::new(), + &mut grid, + CellSize::new(rows, cols), + ); + + let row_text = |row: u32| -> String { + (0..cols) + .map( + |column| match &cells[(row * cols + column) as usize].glyph { + Glyph::Char(ch) => *ch, + Glyph::Cluster(bytes) => std::str::from_utf8(bytes) + .ok() + .and_then(|t| t.chars().next()) + .unwrap_or(' '), + Glyph::Continuation => ' ', + }, + ) + .collect::() + .trim_end() + .to_owned() + }; + + assert!( + row_text(0).contains("Welcome to pmacs"), + "row 0 is the greeting's first line; got {:?}", + row_text(0) + ); + // The discriminating half: with a stale TextView these land on row 0 + // too, and row 1 is blank. + assert!( + row_text(1).contains("C-x C-f"), + "the second line must occupy its own row, not collapse into the \ + first; row 1 = {:?}, row 0 = {:?}", + row_text(1), + row_text(0) + ); + assert!( + !row_text(0).contains("C-x C-f"), + "and must not have been folded into row 0; got {:?}", + row_text(0) + ); +} From 08bc1a153c316fcd59488e46493b5e31497a172d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 16:50:27 -0400 Subject: [PATCH 7/8] docs: record 1b-2 as landed; collapse the journey lane to one block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 on #205, one finding, accepted. #204 is this PR's base, so three places still describing it as open were simply false. - `COHERENCE.md` §2's step-6 row: "(PR open) ends the silence" -> "(#204) ended the silence", and the "flips only on merge" clause is replaced by the reason the row is still Partial at all — a server that starts and then crashes is unsurfaced, which that landing did not touch. - `docs/agent-handoff.md`: the 1b-2 bullet says LANDED (#204), with the same substituted reason. - `docs/active-work.md`: 1b-2's per-stage block is removed rather than relabelled, per rule 4 — it has merged and its durable facts are in the handoff, which is the precondition for removal. That last one exposed a structural problem the merge had created: 1b-2's old lane body had come in *underneath* the unified journey header, so the file carried two journey sections and the first one's text described the second's predecessor. There is now one arc lane with 1b-3 nested under it as the only open stage. Also corrects the reap-ledger lane header, which still read "IMPLEMENTED, PR OPEN" after #202 merged. Same class of staleness, noticed while editing the same file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- COHERENCE.md | 2 +- docs/active-work.md | 82 +++++++++---------------------------------- docs/agent-handoff.md | 7 ++-- 3 files changed, 22 insertions(+), 69 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 43dd0ec..860367e 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -394,7 +394,7 @@ Full verdict table: | 3 | Open real project | **Works at the CLI** | Journey Stage 1a: `resolve_target_buffer` answers `ResolvedTarget::Directory` before the EISDIR-producing load, and `EditorState::open` / the daemon bootstrap dispatch the `path.open-directory` chain, whose fallback is dired (#165's buffer, reached rather than duplicated). Startup no longer fails: an unreadable directory, a crashed resolver, and a cleared handler all report on the status line and leave the session running. Because the listing is async and the bootstrap is synchronous, the commit runs against a destination captured at request time (`pmacs.window.commit_to`) rather than against the ambient frontend | | 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments. Journey Stage 1b-3 adds a welcome in `*scratch*` and `M-x help`; **still Partial** because `C-h` deletes a word (deliberately — §18) and there is no tutorial | | 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config | -| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua`). **Journey Stage 1b-2 (PR open) ends the silence** for a server that fails to *start*: the status line names the command, language and errno once per `(language, root, command)`; the modeline reads `LSP:!` instead of nothing; and `M-x lsp.status` renders `*lsp*` over the `status_buffer_text()` renderer that had existed since M4.8 with no caller. **Still Partial**, and flips only on merge (§25): a server that starts and then *crashes* is still unsurfaced | +| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua`). **Journey Stage 1b-2 (#204) ended the silence** for a server that fails to *start*: the status line names the command, language and errno once per `(language, root, command)`; the modeline reads `LSP:!` instead of nothing; and `M-x lsp.status` renders `*lsp*` over the `status_buffer_text()` renderer that had existed since M4.8 with no caller. **Still Partial**, for a reason unaffected by that landing: a server that starts and then *crashes* is still unsurfaced — `LspEventKind::Crashed` is pushed and no builtin subscriber handles it | | 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI | | 8 | Open terminal | **Works** | Full PTY with scrollback + modeline segment, bound to `C-c t` and configurable through three registered settings (`terminal.default-profile`, `terminal.scrollback-rows`, `terminal.escape-key`) plus named `pmacs.terminal.profiles` (PR #173), and searchable through `M-x terminal.copy-mode` / `C-c C-t`, which materializes the retained scrollback into an ordinary read-only buffer (Stage 2). Named limitations: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there; and there is still **no close/kill command**, which is the remaining half of this step's discoverability gap. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* | | 9 | Build / test | **Works** | Journey Stage 1b-1 (#203): `C-c c` runs `compile.run`, and the first prompt is prefilled from the detected project kind (`pmacs.compile.defaults`, seeded `rust = "cargo build"`, extensible from `init.lua`) via `ProjectKind::Rust` — **not** `Cargo`, see §24. The prompt **captures** its directory rather than re-resolving at accept time, so the command it offers and the directory it runs in cannot drift while the minibuffer waits. Still defaults cwd to the detected project root and parses Rust `-->` errors. Named limitation: after `pmacs ` the active buffer is dired's and pathless, so the cwd falls back to the process cwd — §8's execution-location model owns that, and the degradation stays coherent (no suggestion is offered for a directory with no detected Cargo project) | diff --git a/docs/active-work.md b/docs/active-work.md index 1871bc6..147f568 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -255,75 +255,27 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Journey lane (P1) — 1a, 1b-1, 1b-2 MERGED; 1b-3 PR OPEN +## Journey lane (P1) — 1a, 1b-1, 1b-2 MERGED; 1b-3 PR #205 OPEN **Rewritten, not removed.** Rule 4 removes a lane when its ARC is done; the journey arc is not — 1b-3 is the last stage of the 1b split and is -still open. Stage 1a (#182/#183) and Stage 1b-1 (#203) are on `main` and -their durable facts are in `docs/agent-handoff.md` §1, which is rule 4's -precondition satisfied rather than deferred. +still open. Stages 1a (#182/#183), 1b-1 (#203) and **1b-2 (#204)** are +all on `main` and their durable facts are in `docs/agent-handoff.md` §1, +which is rule 4's precondition satisfied rather than deferred — so their +per-stage blocks are gone from here rather than left to rot as +"PR OPEN". -**#203's merge obligation is DISCHARGED**: `COHERENCE.md` §2's step-9 -row now reads **Works**, §2's keybinding-inversion paragraph records all -three examples answered (the quote itself deliberately unchanged), §20 -Priority 1 and its arc list say "landed", and the handoff bullet says -LANDED. That flip rides *this* branch rather than a standalone docs PR, -because #204 already touches all three files and a separate PR would -re-conflict on every merge. +**#203's merge obligation is DISCHARGED** on this branch: `COHERENCE.md` +§2's step-9 row reads **Works**, §2's keybinding-inversion paragraph +records all three examples answered (the quote itself deliberately +unchanged), §20 Priority 1 and its arc list say "landed", and the +handoff bullet says LANDED. **#204's is discharged too**: §2's step-6 +row names it as landed and stays **Partial** for a reason that landing +did not touch — a server that starts and then *crashes* is still +unsurfaced. Both rode this branch rather than standalone docs PRs, +which would have re-conflicted on every merge. -- **Branch `journey-stage1b2-lsp-guidance`**, worktree - `../pmacs-journey-1b2`, based on `githubsucks/main` @ `fbcf235`, - **integrated with `main` @ `1f290d5` (#203)**. - `docs/journey-stage1b2-lsp-guidance-framing.md` revision 4, three - review rounds closed (round 1: two blocking, three major, one minor; - round 2: two blocking, two cleanups; round 3: one blocking; all - accepted). **Implemented; PR #204 open.** -- **What it is.** `COHERENCE.md` §1.2's canonical silence, journey step - 6: a preconfigured-but-missing language server now reports with - guidance, marks the modeline `LSP:!`, and appears in `M-x lsp.status`. -- **Half of it was already built and unwired.** - `LspManager::status_buffer_text()` and `last_error()` have existed - since M4.8, exposed to Lua and tested, with **no production caller** - and no `*lsp*` buffer, while several `src/lsp.rs` and `src/project.rs` - doc comments refer to that buffer as though it existed. -- **The reporting shape was already adopted twice in `lsp.lua` itself** - (root resolvers, notification subscribers). The canonical case was - silent because nobody had converted it — this finishes an adoption. -- **`COHERENCE.md` §1.2's frequency note was wrong, and it decided the - design.** `LspManager::spawn` returns early *before* both - `status_tracker.ensure` and `clients.insert`, so a failed spawn leaves - **no record**, `pmacs.lsp.list()` cannot see it, and the affinity loop - re-spawns: the real rate is **once per file open**, not once per - project root. Hence **memoize the report, not the failure**. -- **The affinity key is `(language, key_uri)`, and `key_uri` is nil for - markerless files**, which deliberately share one server per language. - Lua cannot index by nil (`t[nil]` raises), so one encoding function - serves both tables with a `u`/`n` discriminator no URI can collide - with. -- **Three tables, three lifetimes**, plus a buffer-keyed projection for - the modeline — that provider runs for every window on every paint, so - deriving an affinity key inside it would invoke user root resolvers - during painting. **A success sweeps every projection sharing the key**, - and the projection has its own `pmacs.buffer.on_removed` teardown - because nothing existing reaches it (`attachments_under` iterates - `attachments`, and a failed buffer has none by construction). -- **ON MERGE of #204, flip the step-6 grade.** §2's step-6 row stays - **Partial** while the PR is open, per §25's landed-evidence rule, and - says so in the row. -- **Stage 1b-3 (welcome buffer, step 4) is unframed** — the last of the - 1b split. -- Recovery from a clean checkout — **the two-argument form does not - work** (`git worktree add ` fails with - `fatal: invalid reference`): - - ```sh - git fetch githubsucks - git worktree add ../pmacs-journey-1b2 \ - -b journey-stage1b2-lsp-guidance \ - githubsucks/journey-stage1b2-lsp-guidance - ``` - -## Journey Stage 1b-3 (P1) — IMPLEMENTED, PR OPEN +### Stage 1b-3 — IMPLEMENTED, PR #205 open - **Branch `journey-stage1b3-welcome`**, worktree `../pmacs-journey-1b3`, based on `githubsucks/main` @ `1f290d5`, **integrated with `main` @ @@ -508,7 +460,7 @@ compatible. githubsucks/test-ambient-config-isolation ``` -## Reap-ledger silent failures — IMPLEMENTED, PR OPEN +## Reap-ledger silent failures — MERGED (#202); kept for its parked follow-ons - **Branch `reap-ledger-silent-failures`**, worktree `../pmacs-reap-ledger`, based on `githubsucks/main` @ `22df6ab`. diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 69a8524..d4542f4 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -332,12 +332,13 @@ commands, read `docs/active-work.md` immediately after this file. because a selected candidate shadows typed text while `accept()` does `session.take()`, the pin asserts `pmacs.minibuffer.selected() == "help"` **before** RET. -- **Journey arc (P1) — Stage 1b-2 IMPLEMENTED, PR open** +- **Journey arc (P1) — Stage 1b-2 LANDED (#204)** (`docs/journey-stage1b2-lsp-guidance-framing.md`, rev 4, three review rounds). `COHERENCE.md` §1.2's canonical silence: a preconfigured language server that is not installed now reports with guidance, marks - the modeline `LSP:!`, and appears in `M-x lsp.status`. Per §25 the - step-6 grade flips only on merge. + the modeline `LSP:!`, and appears in `M-x lsp.status`. §2's step-6 row + **stays Partial** for a reason that landing did not touch: a server + that starts and then *crashes* is still unsurfaced. - **`status_buffer_text()` had existed since M4.8, exposed to Lua and tested, with no production caller and no `*lsp*` buffer** — several `src/lsp.rs` and `src/project.rs` doc comments referred to that From e4686ceea17d7051b71da6f04d51c2de62297c3e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 17:03:27 -0400 Subject: [PATCH 8/8] docs(coherence): correct scorecard rows 2 and 19 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two audited rows were false at this PR's head and one of them is this PR's own doing, so per §25 the correction rides the landing PR rather than a follow-up. Row 19 said the journey suite covers "steps 2, 3, 5". This PR adds step 4; #203 and #204 added 9 and 6 before it. It now carries 45 pins over six steps, and the row names the five §19 scenarios that genuinely remain unwritten instead of an out-of-date count. Row 2 said the journey "runs to step 5 ... thin from step 6 on". That was already stale when #203 and #204 landed and should have ridden them. With 1b complete the journey runs to step 10, and the thin end is now steps 1, 11 and 12 — install, background-work visibility, and session restore. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- COHERENCE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 860367e..670b235 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -95,7 +95,7 @@ remain open to them. | § | Concern | Grade | One-line state | |---|---|---|---| -| 2 | Golden product journey | **Runs to step 5** | `pmacs .` opens the directory (Journey Stage 1a); thin from step 6 on | +| 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 | @@ -112,7 +112,7 @@ remain open to them. | 16 | Semantic frontend | **Strong** | v6..=v21 schema support; production attach remains v20 during the dark panel slice; degradation practiced | | 17 | Distribution | **Missing** | CI is test-only; no binaries, channels, checksums, or update path | | 18 | Onboarding | **Partial** | Journey Stage 1b-3: an unconfigured launch greets in `*scratch*` naming `M-x` and four real bindings, and `M-x help` renders a cheat sheet. Still no tutorial and `C-h` still deletes a word — deliberately, see §18 | -| 19 | Coherence acceptance tests | **Started** | `tests/journey_acceptance.rs` exists (steps 2, 3, 5); the other five scenarios are still unwritten | +| 19 | Coherence acceptance tests | **Started** | `tests/journey_acceptance.rs` carries 45 pins over steps 2, 3, 4, 5, 6 and 9 — the ratchet is real and stages add rows to it. The other five §19 scenarios (workspace lifecycle, worker ownership, config provenance, package lifecycle, extension isolation) are still unwritten | Three cross-cutting patterns explain most of the table; they are detailed in §1.1–§1.3: **substrate without surface**, **the silence