From e26ddd5ec366550c43eb4fddf9b0a33430ea5752 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 18:15:58 -0400 Subject: [PATCH 1/6] =?UTF-8?q?docs(journey):=20frame=20Stage=201b-1=20?= =?UTF-8?q?=E2=80=94=20make=20building=20discoverable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `COHERENCE.md` §20 Priority 1 names Journey Stage 1b as the golden journey's remainder and it had no branch, no framing, and no lane — the only §20 priority with nothing in flight. This frames the first third of it: journey step 9, build or test the project. Scope is a global `C-c c` for `compile.run` plus a first prompt prefilled from the detected project kind instead of empty. Lua, tests and docs; no Rust change, no protocol change. LSP spawn guidance and the welcome buffer are separate stages (§7). Three ground-truth findings the framing rests on: - `ProjectKind::Cargo` does not exist. COHERENCE names it twice; the variant is `ProjectKind::Rust` and line 77 is its doc comment. Lua matches the tag string `pmacs.project.detect` already returns, so the stage needs no new Rust primitive. - The compile cwd is resolved inside `run`, after the prompt closes, so a suggestion built in the command's `fn` would obey a different rule than the run. The stage extracts one resolution and has both consume it. - The last-resort cwd is `std::env::current_dir()` at call time, which in tests is the pmacs repo root — itself a Cargo project. Negative pins are built so that fallback is never reached. Records one limitation rather than fixing it: after `pmacs ` the active buffer is dired's and pathless, so the cwd falls through to the process cwd. That needs COHERENCE §8's execution-location model, not a reach into dired's private handle table. Adds the journey lane to the active-work ledger. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 60 ++ ...urney-stage1b1-compile-defaults-framing.md | 563 ++++++++++++++++++ 2 files changed, 623 insertions(+) create mode 100644 docs/journey-stage1b1-compile-defaults-framing.md diff --git a/docs/active-work.md b/docs/active-work.md index 09c5c9d..9b26399 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -253,6 +253,66 @@ 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) — STAGE 1a MERGED; STAGE 1b-1 FRAMING OPEN, revision 1 + +- **Branch `journey-stage1b1-compile-defaults`**, worktree + `../pmacs-journey-1b1`, based on `githubsucks/main` @ `22df6ab`. + **Framing only; no code, no PR yet.** + `docs/journey-stage1b1-compile-defaults-framing.md` revision 1, no + review rounds yet. +- **Why this lane exists.** `COHERENCE.md` §20 Priority 1 names Journey + Stage 1b as the golden journey's remainder — "the compile binding + + Cargo defaults, LSP spawn guidance, and the welcome buffer" — and it + had no branch, no framing, and no lane. It is the only §20 priority + with nothing in flight. +- **What 1b-1 is.** Journey step 9 only: a global `C-c c` for + `compile.run`, and a first prompt prefilled from the detected project + kind instead of empty. Lua, tests, and docs; **no Rust change and no + protocol change**. 1b-2 (LSP spawn guidance, step 6) and 1b-3 (welcome + buffer, step 4) are separate stages — see the framing §7 for why the + three are not one PR. +- **`ProjectKind::Cargo` does not exist.** `COHERENCE.md` names it twice + (§2's step-9 row and §20 Priority 1); the variant is + `ProjectKind::Rust` (`src/project.rs:78`) and line 77 is its doc + comment. The audit read the comment. Lua matches on the **tag string** + `"rust"` that `pmacs.project.detect` returns, so no Rust primitive is + needed. The PR corrects both COHERENCE sites. +- **The suggestion and the run must share one cwd resolution.** Today + the cwd is computed *inside* `pmacs.compile.run` + (`builtin/runtime/compile.lua:765`), after the prompt has closed, so a + suggestion computed in the command's `fn` would obey a different rule + than the run. The stage extracts it and exposes + `pmacs.compile.context()`, consumed by both. +- **The trap the acceptance is designed around.** The last-resort cwd is + `std::env::current_dir()` evaluated at call time + (`pmacs-protocol/src/message.rs:1703-1706`), so in tests it is the + **test runner's cwd — the pmacs repo root, itself a Cargo project**. + `compile_mode_acceptance.rs:1721` already pins this. A pin asserting + "no Cargo suggestion" that reaches the fallback would report pmacs's + own `Cargo.toml`. Negative pins therefore use a fixture carrying a + *different* marker so the fallback is never consulted; + `set_search_boundary` does **not** help here, because it only clamps a + walk that starts below the boundary. +- **Known limitation, deliberately not fixed.** After `pmacs ` the + active buffer is dired's and **pathless** (`pmacs.buffer.create` never + assigns a path; dired compensates through its own module-local + `handle_for_buffer`, `dired.lua:205-217`), so the cwd falls through to + the process cwd. Launched from elsewhere that is the wrong directory. + The fix is `COHERENCE.md` §8 (First-Class Execution Locations), a + model gap; reaching into dired's private table for one string would + add an interaction island. +- Recovery from a clean checkout — **the two-argument form does not + work** (`git worktree add ` fails with + `fatal: invalid reference`, because after a bare fetch no local branch + exists): + + ```sh + git fetch githubsucks + git worktree add ../pmacs-journey-1b1 \ + -b journey-stage1b1-compile-defaults \ + githubsucks/journey-stage1b1-compile-defaults + ``` + ## 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-stage1b1-compile-defaults-framing.md b/docs/journey-stage1b1-compile-defaults-framing.md new file mode 100644 index 0000000..b79ac64 --- /dev/null +++ b/docs/journey-stage1b1-compile-defaults-framing.md @@ -0,0 +1,563 @@ +# Journey Stage 1b-1 — make building discoverable + +**Status: framing, rev 1 — awaiting approval.** +**Serves `COHERENCE.md` §2 (the golden product journey, step 9), §19 +(coherence acceptance tests), §20 Priority 1.** + +## 0. Revision history + +- rev 1 (2026-07-30) — first framing. Scouted against `githubsucks/main` + @ `22df6ab` (test-ambient isolation framing #201). + +## 1. What this stage is, and what it is not + +`COHERENCE.md` §20 Priority 1 names the remainder of the journey arc: + +> Journey Stage 1b is the named remainder: the compile binding + Cargo +> defaults, LSP spawn guidance, and the welcome buffer. + +Those three are unrelated in mechanism, in failure mode, and in cost. +This stage takes **only the first**: journey step 9, *build or test the +project*. LSP spawn guidance (step 6) and the welcome buffer (step 4) +get their own stages; §7 states the split and why. + +Nothing here is a new Rust primitive. The stage is Lua, tests, and docs. + +## 2. Ground truth + +Every claim below was read in the tree at `22df6ab`, not inferred from a +name. + +### 2.1 `compile.run` has no binding, and the docs say so + +`compile.lua` binds four global sequences (`:1183-1188`): `M-g n`, +`M-g p`, `C-x \``, `M-!`. `bind_slot_keys` (`:220-240`) binds +`RET`/`n`/`p`/`q`/`C-c C-k`/`g` at **buffer** scope inside a generated +buffer. `builtin/keymaps/default.lua` never mentions compile. There is +no third keymap file — `builtin/keymaps/` contains `default.lua` alone. + +`docs/keybindings.md:191-192` states it outright: + +> `compile.run` and `compile.recompile` are available through `M-x`; no +> global key is assigned to them. + +### 2.2 The first prompt is empty + +```lua +-- builtin/runtime/compile.lua:1131-1144 +name = "compile.run", +fn = function() + local last = pmacs.compile._last + pmacs.minibuffer.read { + prompt = "Compile command: ", + history = "compile", + initial = last and last.cmdline or "", +``` + +`_last` is set only by a completed `pmacs.compile.run` (`:1094`), so on a +fresh session `initial` is `""`. The user is asked what to build and +given nothing to build with. + +`initial` does reach the user: `Minibuffer::begin` calls +`replace_contents(&session.initial)` (`src/minibuffer.rs:106-107`), and +`contents()` (`:129`) reads it back. The prefill mechanism works; it is +handed an empty string. + +### 2.3 `ProjectKind::Cargo` does not exist — the variant is `Rust` + +**`COHERENCE.md` is wrong here, in two places.** Its §2 step-9 row and +its §20 Priority 1 paragraph both say "`ProjectKind::Cargo` existing +(`src/project.rs:77`)". Line 77 is a doc comment; the variant on line 78 +is `Rust`: + +```rust +// src/project.rs:77-78 +/// A Cargo workspace (`Cargo.toml`). +Rust, +``` + +The marker that produces it is `{ name: "Cargo.toml", kind: +ProjectKind::Rust, is_directory: false }` (`:148-150`). The audit read +the comment and named the comment. This framing corrects both COHERENCE +sites (§8). + +The correction is not cosmetic: it decides what the Lua side matches on. + +### 2.4 Lua already sees the project kind — as a tag string + +`pmacs.project.detect(path)` returns a table (`src/lua_bindings/mod.rs:11683-11694`): + +```rust +t.set("root", root.display().to_string())?; +t.set("kind", kind.tag())?; +t.set("language_id", kind.default_language_id())?; +``` + +`ProjectKind::Rust.tag()` is `"rust"` (`src/project.rs:101`). So the Lua +key is the string `"rust"`, and **no Rust change is needed to learn the +project kind**. + +`detect` accepts a directory as well as a file: `walk_for_marker` uses +`start` unchanged when it is not a file (`src/project.rs:225-229`). + +### 2.5 The cwd is resolved *inside* `run`, after the prompt has closed + +```lua +-- builtin/runtime/compile.lua:765 +local cwd = opts.cwd or project_root_of_active() or daemon_working_directory() +``` + +The interactive `fn` that builds the prompt cannot see this. Any +suggestion computed in the `fn` today would be computed from a different +rule than the one the run obeys — which is the failure this stage must +not ship. §3.1 is about closing that gap before adding the suggestion, +not after. + +### 2.6 The active buffer at the journey moment is **pathless** + +`project_root_of_active()` (`:600-609`) needs `buf:path()`. Directly +after `pmacs .` the active buffer is dired's, created by +`pmacs.buffer.create(name)` (`dired.lua:506`) — a name, never a path. +`buf:path()` returns `file_path()` mapped to a string +(`src/lua_bindings/mod.rs:1242-1248`), so it is `nil`. + +This is not a guess. dired itself compensates, and its own helper is the +evidence: + +```lua +-- builtin/runtime/dired.lua:205-217 +local function current_directory() + local buf = pmacs.window.buffer() + if buf ~= nil then + local ok, path = pcall(function() return buf:path() end) + if ok and path then ... end + local h = handle_for_buffer(buf) -- <-- the pathless case + if h then return h.path end + end + return canonicalize(".") +end +``` + +`handle_for_buffer` is a **module-local** table. Compile cannot reach it, +and reaching for it would make compile depend on dired — a new +interaction island for one directory string. §5 decides against it and +§6 states the residual gap. + +### 2.7 The last-resort cwd is the *process* cwd, evaluated at call time + +`daemon_working_directory()` reads +`pmacs.instance.identity().working_directory`, which is: + +```rust +// pmacs-protocol/src/message.rs:1703-1706 +working_directory: std::env::current_dir() + .ok() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(), +``` + +Two consequences, both load-bearing: + +- **In production**, `pmacs .` is launched from the project directory, so + the fallback happens to be right. `pmacs ~/code/proj` launched from + `~` resolves to `~`, and the fallback is wrong. That is a pre-existing + step-9 defect this stage does not fix (§6.1). +- **In tests**, it is the *test runner's* cwd — the pmacs repo root, + **which is a Cargo project**. `compile_mode_acceptance.rs:1721-1733` + already pins exactly this (`r1f8_inherited_cwd_resolves_to_the_daemon_working_directory`), + and `:1382` already warns that "fallback would search the test + process's cwd". Any pin that asserts *no* Cargo suggestion is at the + mercy of where `cargo test` was invoked unless it is designed to never + reach the fallback. §5.2 designs for that; it is the single largest + trap in this stage. + +### 2.8 `C-c c` is free, and `C-c` is not CUA copy + +Global `C-c` sequences in the tree: `C-c a`/`f`/`h`/`H`/`i`/`o`/`r`/`s`/`y` +(`lsp.lua`), `C-c t` (`terminal.lua:203`), `C-c @ …` (folding). +`C-c c` is unbound. Copy is `M-w` (`builtin/keymaps/default.lua:114`) — +the CUA trio was deliberately not taken — so binding under the `C-c` +prefix collides with nothing in the default map. + +`terminal.lua` binds its own `C-c t`; `lsp.lua` binds its own `C-c o`. +A runtime module owning its global binding is the established pattern, +and this stage follows it rather than editing `default.lua`. + +### 2.9 dired already decided the prompt shape + +`C-x d` prefills its prompt and **deliberately refuses a completion +source** (`dired.lua:749-756`): a candidate list makes RET-on-empty open +whatever sorts first, and a selected candidate shadows typed text. The +same reasoning applies to a compile command, so this stage prefills and +adds no completion source. + +## 3. Design + +### 3.1 One resolution, consumed twice + +Extract the `:765` expression into a helper and expose a read-only view +of it: + +```lua +local function resolve_cwd(explicit) + return explicit or project_root_of_active() or daemon_working_directory() +end + +--- Where the next compile would run, and what kind of project is +--- detected *from that directory*. Public getter (API conventions): +--- `{ cwd = string|nil, kind = string|nil }`. +function pmacs.compile.context(explicit_cwd) +``` + +`run` calls `resolve_cwd(opts.cwd)`; the interactive `fn` calls +`pmacs.compile.context()`. **The suggestion is therefore a function of +the directory the command will execute in**, and the two cannot drift. + +`kind` is `pmacs.project.detect(cwd).kind` — detection *from* the cwd, +which is not the same as "the cwd is the root": `opts.cwd = /proj/src` +in a Cargo workspace yields kind `rust` with root `/proj`. That is +correct (cargo works from a subdirectory) and is stated so a reader does +not read `kind` as "this directory is a project root". + +`project_root_of_active()` already calls `detect` once to get a root; +`context` then calls it again on the result. The second call is +redundant in that branch and is kept anyway, because the alternative is +two different rules for where `kind` comes from depending on which +branch produced the cwd. One rule, stated as: **`kind` is always +`detect(cwd)`.** + +### 3.2 The default table + +```lua +--- Default compile command per detected project kind, keyed by the +--- tag `pmacs.project.detect` returns. Assign into this table from +--- `init.lua` to add or override one. +pmacs.compile.defaults = { rust = "cargo build" } +``` + +**Only `rust` is seeded, and that is a decision rather than an +omission.** Rust has one answer. Node does not (`npm` / `yarn` / `pnpm` +/ a `scripts.build` that may not exist); Python does not; Go's build and +test are different commands with equal claim. A wrong prefill is worse +than an empty one — the user must first delete it, then type. The table +exists so a user or a package can add the answer *they* know. + +`cargo test` is reachable by editing the prefill. The prompt prefills +one string; offering both would need a candidate list, which §2.9 +already ruled out for this prompt. + +### 3.3 Precedence at the prompt + +``` +last.cmdline -- unchanged; a session that has compiled keeps its command + or defaults[kind] -- new + or "" -- unchanged +``` + +`last` winning is deliberate: a user who ran `cargo test` once should get +`cargo test` back, not be reset to `cargo build`. This is a preservation +pin (§5.4, P1), not an accident of ordering. + +### 3.4 The table is user-writable, so reading it is guarded + +`pmacs.compile.defaults` is public and assignable, which means a +metatable with a throwing `__index`, a non-string value, or a +non-table replacement all have to be survivable. The module already +holds this discipline for a hostile rule container (`validated_rules`, +round-2 finding 3: shell-command "must neither surface compile-rule +warnings nor fail on a hostile rule container"). + +The lookup therefore runs under `pcall` and accepts a value only when it +is a non-empty string. Anything else yields `""` — the pre-stage +behavior. **A broken `defaults` degrades to today's prompt; it never +prevents compiling.** + +### 3.5 The binding + +```lua +pmacs.keymap.bind { scope = "global", sequence = "C-c c", command = "compile.run" } +``` + +In `compile.lua`, beside the four existing global binds (§2.8). + +Two reachability limits it inherits, both pre-existing and both stated +rather than discovered later: + +- Inside a **terminal** window `C-c` is consumed as the escape key, so + `C-c c` does not arrive. `COHERENCE.md` §2 already records this for + `C-c t`. `M-x compile.run` still works there. +- The repl package binds `C-c` at **buffer** scope + (`builtin/packages/repl/init.lua:300`), which shadows the global + prefix in a repl buffer. Same escape hatch. + +`compile.recompile` gets no global binding: `g` in `*compilation*` +already covers rerun, and adding a second global chord for it is scope +this stage has no journey argument for. + +## 4. What this changes for the journey + +Walking `COHERENCE.md` §2 on a Rust project, unconfigured: + +| | before | after | +|---|---|---| +| launch | `pmacs .` lists the directory (Stage 1a) | unchanged | +| open a file | `RET` visits it (Stage 1a) | unchanged | +| build | *no key exists*; `M-x compile.run` → empty prompt | `C-c c` → `Compile command: cargo build` | +| accept | — | RET runs it in the detected root | +| errors | `M-g n` walks them | unchanged | + +Step 9's verdict row moves from **Partial** to **Works**; step 10 stops +being gated on the user already knowing `M-x compile.run`. + +## 5. Acceptance + +### 5.0 Two labels, as Stage 1a established + +**N** — new behavior, must fail on full revert. **P** — preservation, +legitimately green on the pre-image, falsified only by a named targeted +mutation. Stage 1a's §6.0 is the reason the distinction is kept: an +equivalence assertion between two implementations that already agree +proves nothing. + +Every pin below names the mutation that falsifies it. `scripts/bite` is +run against the suite before the PR opens. + +### 5.1 Where the pins live + +- **`tests/journey_acceptance.rs`** gains a step-9 section. This file is + the ratchet — *stages add rows, none removes them*. Its pins go + through the **real** entry points: `EditorState::open` on a directory, + a dispatched `RET`, a dispatched `C-c c`. Nothing calls + `pmacs.compile.context()` directly in this file. +- **`tests/compile_mode_acceptance.rs`** gains the module-contract pins: + `context()`'s shape, and the hostile-table guards. + +### 5.2 The fixture problem, and its only safe shape + +Per §2.7, the last-resort cwd is the test runner's cwd, and the test +runner's cwd is a Cargo project. **A pin that asserts "no `cargo build` +suggestion" and reaches the fallback will report the pmacs repo's own +`Cargo.toml` as the fixture's answer.** It would pass or fail on where +`cargo test` was invoked from. + +The negative pins are therefore built so the fallback is **never +consulted**: the fixture carries a *different* project marker, so +`project_root_of_active()` resolves inside the fixture and returns +before `daemon_working_directory()` is reached. + +``` +rust_fixture/ Cargo.toml, main.rs +mixed_fixture/ Cargo.toml, main.rs + sub/ package.json, index.js +``` + +`mixed_fixture` is what makes N4 discriminating: the file opened is +`sub/index.js`, the nearest marker is `package.json` (kind `node`, no +default), and the *outer* marker is Cargo. A suggestion computed from +anything other than the resolved cwd — the outermost marker, the launch +directory, the process cwd — produces `cargo build` here. The correct +implementation produces `""`. + +`pmacs.project.set_search_boundary` is set to the fixture root in each +test that detects, so a stray marker above the tempdir (a developer's +`/tmp/.git`) cannot leak in. Note what it does **not** do: it clamps the +upward walk from a start *below* the boundary, so it is no protection at +all for the fallback path, whose start is the repo root. That is why the +fixture shape above, not the boundary call, is the actual defense. + +### 5.3 New-behavior pins + +**N1 — the chord reaches the command.** +Launch on `rust_fixture`, `RET` on `main.rs`, dispatch `C-c c`; assert +`pmacs.minibuffer.is_active()`. +*Falsifier:* remove the `keymap.bind` line — the chord is unbound, no +session opens. +*Why it is separate from N2:* a prefill assertion alone would stay green +if the binding were removed and the prompt were opened some other way. +The binding is the thing COHERENCE says is missing; it gets its own pin. + +**N2 — the prompt is prefilled from the project kind.** +Same walk; assert `pmacs.minibuffer.contents() == "cargo build"`. +*Falsifier:* drop the `defaults[kind]` term from the precedence chain — +contents become `""`. + +**N3 — the prefill is executable as offered.** +Same walk; assert the prompt contents are exactly +`pmacs.compile.defaults[pmacs.compile.context().kind]`, and that +`context().cwd` is the fixture root. +*Falsifier:* compute the prefill from `project_root_of_active()` instead +of from `context()`. In the plain fixture the two agree, so this pin +alone is **not** sufficient — which is exactly why N4 exists. + +**N4 — the suggestion follows the directory the run will use.** +Launch on `mixed_fixture`, `RET` into `sub/index.js`, dispatch `C-c c`; +assert contents are `""` and `context().kind == "node"`. +*Falsifier:* any rule that is not "detect from the resolved cwd" — take +the outermost marker, or the launch directory, or the process cwd — all +yield `cargo build`. +This is the pin that carries §3.1's whole claim. + +**N5 — `context()` is total in a launched session.** +Property, not a constant, because the value is environment-dependent +(§2.7): after a launch, `context().cwd` is non-nil, and `context().kind` +equals `pmacs.project.detect(context().cwd)`'s kind (both nil, or both +the same string). Asserted with a dired buffer active — the pathless +case — so the fallback branch is the one under test. +*Falsifier:* make `resolve_cwd` return `nil` when the active buffer has +no path, i.e. drop the `daemon_working_directory()` term. +*What it deliberately does not assert:* which directory. Pinning that +would pin the test runner's cwd. + +### 5.4 Preservation pins + +**P1 — `_last` still outranks the kind default.** +Run a compile with an explicit cmdline that is not `cargo build`, then +open the prompt in the Rust fixture; assert the contents are the last +cmdline. +*Targeted mutation:* reorder the precedence chain to put `defaults[kind]` +first. Green on the pre-image (there was no default), red under the +mutation. + +**P2 — a hostile `defaults` cannot break compiling.** +Three cases in `compile_mode_acceptance`: `defaults` replaced by a +non-table; a `__index` metatable that raises; a non-string entry for +`rust`. In all three the prompt opens with `""` and `compile.run` still +executes a typed command. +*Targeted mutation:* remove the `pcall` / type guard — the raising case +propagates out of the command and no prompt opens. + +**P3 — the existing compile bindings are unchanged.** +`M-g n`, `M-g p`, `C-x \``, `M-!` still dispatch to their commands, and +`compile.run` is still reachable through `M-x`. +*Targeted mutation:* the new `bind` call written as an `unbind`+`bind` +pair over the wrong sequence. + +### 5.5 Gates + +The full suite from `CLAUDE.md`, plus `compile_mode_acceptance`, +`journey_acceptance`, `dired_acceptance`, and `find_file_acceptance` as +the touched suites. Local runs must control all five bootstrap-storage +variables (`XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, +`XDG_CACHE_HOME`, `PMACS_STATE_HOME`) — the ambient-root isolation lane +(#201) is **framing only**, so the workaround is still required and +`compile_mode_acceptance` is one of the suites that goes red without it. + +## 6. Named limitations — stated, not discovered later + +### 6.1 `pmacs ` from elsewhere still resolves the wrong cwd + +Launched as `pmacs ~/code/proj` from `~`, the active buffer is dired and +pathless (§2.6), so the cwd falls through to the process cwd `~` +(§2.7). Compile would then run in `~`, and — consistently, since §3.1 +ties them — suggest nothing. + +**This stage does not fix it.** The fix needs a notion of "the directory +this session is working in" that is not any one module's private table, +which is `COHERENCE.md` §8 (First-Class Execution Locations) — a model +gap, not wiring. Reaching into dired's `handle_for_buffer` would make +compile depend on dired for one string and add exactly the kind of +interaction island §6 of COHERENCE is about. + +What this stage does guarantee is that the failure is **coherent**: the +suggestion describes the directory the command will run in, whatever +that directory turns out to be. The user is never offered `cargo build` +for a directory with no `Cargo.toml`. + +N5 pins the property; it deliberately does not pin the value. + +### 6.2 The default is per-kind, not configurable through the registry + +`pmacs.compile.defaults` is a plain Lua table, not a registered setting. +It cannot be one: `ConfigValue` is four scalars, so a kind→command map +is not expressible. A scalar `compile.default-command` that overrides +the table *is* expressible and is **deferred, not skipped** — it is one +more precedence step and a registry entry, and it belongs with the +config-adoption work (§20 Priority 6) rather than bolted on here. + +### 6.3 One binding does not retire the inversion + +`COHERENCE.md` §2 keeps a standing observation verbatim: keybinding +coverage is inverted relative to frequency, with `C-c @ C-M-s` bound +while opening a file, opening a terminal, and running a build were not. +Two of the three have been answered (#162, #173); this stage answers the +third, and §8 updates the paragraph accordingly. The *quote* stays as +written, because it names a bias in how new work gets bound rather than +three omissions. + +## 7. Staging — why 1b is split + +`COHERENCE.md` §20 bundles three items under "Stage 1b". They share a +priority and nothing else: + +| | subsystem | shape | risk | +|---|---|---|---| +| **1b-1** (this) | compile + project | wiring, Lua only | low | +| 1b-2 | LSP lifecycle | a failure that is currently *silent* (§1.2) must become visible without becoming noise | medium | +| 1b-3 | startup buffer | new content, plus §18's `C-h`-deletes-a-word problem | low, but touches the default keymap | + +One feature, one branch, one PR. 1b-2 is the hard one — the silence +asymmetry is a design question about *when* to speak, not a wiring +question — and bundling it with a keybinding would hold the cheapest +journey fix in the tree behind the most contested one. + +## 8. Coherence impact + +Per `CLAUDE.md` and `COHERENCE.md` §20's standing process change. + +- **Journey steps touched:** 9 directly (Partial → Works); 10 indirectly + — it was "gated entirely on step 6 or 9 succeeding first". +- **Interaction islands:** none added. The prompt is the existing + minibuffer; the binding joins the existing `C-c` prefix; the kind + comes from the existing detector. `pmacs.compile.defaults` is an + extensible table, not a new modal surface. +- **Config registry adoption:** none, deliberately — §6.2 gives the + mechanism reason and names the deferred scalar. +- **Background-work attribution:** unchanged. Compile already spawns + through the process-group machinery; this stage changes what is typed + into the prompt, not what is spawned or how it is tracked. +- **Doc updates riding this PR** (§25 requires it): + - `COHERENCE.md` §2 step-9 verdict row → Works, and the + `ProjectKind::Cargo` → `ProjectKind::Rust` correction **in both + places** (§2 row and §20 Priority 1). §24 gains the drift entry. + - `COHERENCE.md` §2's post-table paragraph: "Running a build still has + no binding" → answered, with the quote itself left intact (§6.3). + - `docs/keybindings.md`: the `C-c c` row, and the removal of the + "no global key is assigned to them" sentence at `:191`. + - `docs/agent-handoff.md` §1: the journey arc bullet gains Stage 1b-1. + +## 9. Open questions for review + +- **Q#J1 — is `C-c c` the right chord?** It is free, it is under the + established `C-c` prefix, and it matches what most Emacs distributions + bind compile to. The alternative worth naming is `C-c C-c`, which is + more finger-friendly but is the chord many major modes claim + buffer-locally, so a global one would be shadowed unpredictably later. +- **Q#J2 — should `rust` be the only seeded default?** §3.2 argues yes + on the grounds that a wrong prefill costs more than an empty one. The + counter-argument is that `go build` and `make` are about as + unambiguous as `cargo build`, and seeding them would make the table + read as a real registry rather than a Rust special case. +- **Q#J3 — should the prefill be selected, so typing replaces it?** + Emacs leaves the prefill unselected and the point at the end. dired's + prefill does the same. Matching them means "accept" is RET and + "replace" is a kill-line first. Changing it is a minibuffer-wide + behavior change and out of scope, but it is the ergonomic difference a + user will notice first. + +## 10. Ledger + +Branch `journey-stage1b1-compile-defaults`, worktree +`../pmacs-journey-1b1`, based on `githubsucks/main` @ `22df6ab`. +Framing only; no code, no PR yet. + +Recovery from a clean checkout — the two-argument form of +`git worktree add` does not work for a remote-only branch (it fails with +`fatal: invalid reference`, because after a bare fetch no local branch +exists): + +```sh +git fetch githubsucks +git worktree add ../pmacs-journey-1b1 \ + -b journey-stage1b1-compile-defaults \ + githubsucks/journey-stage1b1-compile-defaults +``` From 44c3be1ed45f74ddbc3db9929868a29dd97d6741 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 18:40:34 -0400 Subject: [PATCH 2/6] =?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 Two blocking, two major. All four accepted and verified in the code before the doc changed. Sharing one cwd resolver between the prompt and the run does not prevent drift. `pmacs.minibuffer.read` is asynchronous, nothing freezes the active window while a prompt is open, and `run` re-resolved at accept time — so the prompt could offer `cargo build` for A and execute in B. Two calls to the same resolver at two different moments are still two different answers. The interactive command now captures `context()` and passes its `cwd` through to `run`. This is Journey Stage 1a's `commit_to` discipline on a smaller seam: capture at request time, never re-derive from whatever is ambient when the async work lands. No pin crossed the accept boundary. N1-N5 compared values the prompt and the resolver had already agreed on, so a wrong directory inside `on_accept` — exactly the defect above — passed every one of them. Two pins now accept: N3 switches windows between prompt and RET and reads the directory back two ways, and N4 accepts `cargo build` unedited in a real Cargo fixture and asserts a clean exit. N3's old falsifier was not discriminating: `project_root_of_active()` already returns the innermost root, so re-detecting from it yields the same kind and the pin would have stayed green. The mixed-fixture pin is now N5 with a falsifier it actually catches, and says what it does not. And §6.1 contradicted §3.1 — a Cargo workspace subdirectory holds no `Cargo.toml` and correctly receives `cargo build`. The predicate is a detected project, not a file in that one directory. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 15 +- ...urney-stage1b1-compile-defaults-framing.md | 166 ++++++++++++++++-- 2 files changed, 159 insertions(+), 22 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 9b26399..9c9b97d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -253,13 +253,22 @@ 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) — STAGE 1a MERGED; STAGE 1b-1 FRAMING OPEN, revision 1 +## Journey lane (P1) — STAGE 1a MERGED; STAGE 1b-1 FRAMING OPEN, revision 2 - **Branch `journey-stage1b1-compile-defaults`**, worktree `../pmacs-journey-1b1`, based on `githubsucks/main` @ `22df6ab`. **Framing only; no code, no PR yet.** - `docs/journey-stage1b1-compile-defaults-framing.md` revision 1, no - review rounds yet. + `docs/journey-stage1b1-compile-defaults-framing.md` revision 2, one + review round closed (two blocking, two major, all accepted). +- **Round 1's blocking finding is the Stage 1a lesson repeating.** + Sharing one cwd resolver between the prompt and the run is *not* + enough: `pmacs.minibuffer.read` is async, the active window can change + while the prompt is open, and `run` re-resolved at accept time — so + the prompt could offer `cargo build` for A and execute in B. The + interactive command now **captures** `context()` and passes its `cwd` + through to `run`, which is `commit_to`'s discipline on a smaller seam. + The second blocker was that no pin crossed the accept boundary at all, + so that defect passed every proposed pin. - **Why this lane exists.** `COHERENCE.md` §20 Priority 1 names Journey Stage 1b as the golden journey's remainder — "the compile binding + Cargo defaults, LSP spawn guidance, and the welcome buffer" — and it diff --git a/docs/journey-stage1b1-compile-defaults-framing.md b/docs/journey-stage1b1-compile-defaults-framing.md index b79ac64..3a075d1 100644 --- a/docs/journey-stage1b1-compile-defaults-framing.md +++ b/docs/journey-stage1b1-compile-defaults-framing.md @@ -1,11 +1,42 @@ # Journey Stage 1b-1 — make building discoverable -**Status: framing, rev 1 — awaiting approval.** +**Status: framing, rev 2 — awaiting review round 2.** **Serves `COHERENCE.md` §2 (the golden product journey, step 9), §19 (coherence acceptance tests), §20 Priority 1.** ## 0. Revision history +- rev 2 (2026-07-30) — review round 1. Two blocking, two major; all four + accepted, all four verified in the code first. + - **§3.1 did not actually prevent the drift it claimed to.** + `pmacs.minibuffer.read` is asynchronous: `on_accept` runs later, and + the active window can change while the prompt is open. `run` then + re-resolved the cwd from whatever was active *at accept time*, so the + prompt could say `cargo build` for A and execute in B. **Sharing one + resolver is not enough — the resolution has to be captured.** The + interactive `fn` now captures `context()` and passes its `cwd` + through to `run`. This is the Stage 1a lesson repeating: the + destination is captured at request time, not re-derived at commit + time (`commit_to`, §4.4 of that framing). + - **No pin accepted the prompt or observed a spawned run.** N1–N5 all + compared values that the prompt and the resolver had already agreed + on; a wrong cwd inside `on_accept` — exactly the defect above — + passed every one of them. Two pins now cross the accept boundary: + N3 (the captured directory survives a window switch) and N4 (accept + `cargo build` in a real Cargo fixture, assert the `Directory:` header + and a clean exit). + - **N3's named falsifier was not discriminating.** + `project_root_of_active()` already detects from the active file and + returns the *innermost* root, so re-detecting from that root yields + `node` again in `mixed_fixture/sub`. The mutation rev 1 named would + have left the pin green. N3 is repurposed to the prompt-to-run + handoff, and the mixed-fixture pin (now N5) states a falsifier it + actually catches. + - **§6.1 contradicted §3.1.** A Cargo *workspace subdirectory* + contains no `Cargo.toml` and correctly receives `cargo build`, so + "never offered `cargo build` for a directory with no `Cargo.toml`" + was false on the design's own terms. Reworded to *no detected Cargo + project*. - rev 1 (2026-07-30) — first framing. Scouted against `githubsucks/main` @ `22df6ab` (test-ambient isolation framing #201). @@ -210,8 +241,54 @@ function pmacs.compile.context(explicit_cwd) ``` `run` calls `resolve_cwd(opts.cwd)`; the interactive `fn` calls -`pmacs.compile.context()`. **The suggestion is therefore a function of -the directory the command will execute in**, and the two cannot drift. +`pmacs.compile.context()`. + +**Sharing the resolver is necessary and not sufficient.** +`pmacs.minibuffer.read` is asynchronous — `on_accept` runs an arbitrary +time later, and nothing freezes the active window while a prompt is +open. A mouse click, a second frontend, or a background open can change +what `project_root_of_active()` answers between the prompt and the RET. +Two calls to the same resolver at two different moments are still two +different answers, and the user is then shown a suggestion for A and +given a run in B. + +So the interactive command **captures** the resolution and hands it +through: + +```lua +fn = function() + local last = pmacs.compile._last + local ctx = pmacs.compile.context() -- captured once, here + pmacs.minibuffer.read { + prompt = "Compile command: ", + history = "compile", + initial = last and last.cmdline or default_for(ctx.kind) or "", + on_accept = function(cmdline) + if cmdline == nil or cmdline == "" then return end + pmacs.compile.run(cmdline, { cwd = ctx.cwd }) -- the same ctx + end, + } +end +``` + +This is Journey Stage 1a's `commit_to` lesson on a smaller seam: the +destination is captured when the request is made and revalidated at +commit, never re-derived from whatever happens to be ambient when the +async work lands. The mechanism differs — compile needs no scope +override, only the value — but the failure it prevents is the same one. + +`ctx.cwd` is passed through verbatim, **including `nil`**. A `nil` cwd +means every resolution step failed, and `run`'s header renders +`(unknown)` exactly as it does today; substituting a re-resolution there +would reintroduce the drift for the one case least able to tolerate it. + +**Only the interactive command captures.** `pmacs.compile.run(cmdline)` +called programmatically still resolves at call time, which is what a +caller with no prompt in between means by "here". + +With both halves in place: **the suggestion is a function of the +directory the command will execute in**, and the two cannot drift — +neither across the two resolutions nor across the wait for input. `kind` is `pmacs.project.detect(cwd).kind` — detection *from* the cwd, which is not the same as "the cwd is the root": `opts.cwd = /proj/src` @@ -351,7 +428,7 @@ mixed_fixture/ Cargo.toml, main.rs sub/ package.json, index.js ``` -`mixed_fixture` is what makes N4 discriminating: the file opened is +`mixed_fixture` is what makes N5 discriminating: the file opened is `sub/index.js`, the nearest marker is `package.json` (kind `node`, no default), and the *outer* marker is Cargo. A suggestion computed from anything other than the resolved cwd — the outermost marker, the launch @@ -381,23 +458,67 @@ Same walk; assert `pmacs.minibuffer.contents() == "cargo build"`. *Falsifier:* drop the `defaults[kind]` term from the precedence chain — contents become `""`. -**N3 — the prefill is executable as offered.** -Same walk; assert the prompt contents are exactly -`pmacs.compile.defaults[pmacs.compile.context().kind]`, and that -`context().cwd` is the fixture root. -*Falsifier:* compute the prefill from `project_root_of_active()` instead -of from `context()`. In the plain fixture the two agree, so this pin -alone is **not** sufficient — which is exactly why N4 exists. +**N3 — the prompt's directory survives a window switch.** +Launch on `rust_fixture`, `RET` on `main.rs`, dispatch `C-c c` — the +prompt is now open and has captured A. **Then open a file in a second, +unrelated directory B**, so the active buffer's project is no longer A. +Replace the minibuffer contents with `pwd` and dispatch `RET`. +Assert `*compilation*` contains `Directory: ` **and** that `pwd`'s own +output is `` — two independent readings of the same claim, one from +the header pmacs writes and one from the shell that actually ran. +*Falsifier:* drop `{ cwd = ctx.cwd }` from `on_accept`, i.e. rev 1's +design. `run` re-resolves at accept time, finds B, and both readings say +B. +*Why `pwd` and not `cargo build`:* the subject here is the directory, and +a cheap command keeps the pin's failure message about the directory. N4 +is the pin that runs the real thing. +*Why the contents are set rather than typed:* the prompt is opened +through the real chord; only the editing of an already-open prompt is +short-circuited, which is the same split `find_file_acceptance` documents +at its head. -**N4 — the suggestion follows the directory the run will use.** +**N4 — the offered command runs, in the offered directory.** +A real Cargo fixture: `Cargo.toml`, `src/main.rs` with an empty `main`. +Launch on it, `RET` on `main.rs`, dispatch `C-c c`, dispatch `RET` +**without editing** — accepting exactly what was offered. Assert +`*compilation*` contains `Directory: ` and the clean-exit marker +`[compilation exited with code 0]`. +*Falsifier:* the same `{ cwd = ... }` removal as N3, and independently +any prefill that is not a runnable command. +*Why this pin has to exist:* every other pin in this stage compares +values that the prompt and the resolver already agree on. A wrong +directory inside `on_accept` — the exact defect rev 1 shipped — passes +all of them. This one crosses the accept boundary and observes a real +process. +*Operational detail:* the fixture builds into its own tempdir `target/`, +so it takes no lock the enclosing `cargo test` holds and leaves nothing +behind. The pin runs when `cargo` resolves on PATH and skips with a +message otherwise; **`PMACS_REQUIRE_CARGO_BUILD` only tolerates +absence** — presence of the binary decides execution, and the variable +makes a missing binary fatal in CI. That is the `PMACS_REQUIRE_BASH` +arming shape from #200, and it is stated here because getting the +polarity backwards is how a required pin becomes a silent skip. +*Named risk:* this is the only pin that depends on a working toolchain in +the fixture directory. If it proves flaky in CI, the fallback is to keep +the accept-and-observe shape and substitute a command with no toolchain +dependency — the prompt-to-run handoff is the claim, and `cargo build` is +the most faithful witness of it, not the only possible one. + +**N5 — the suggestion follows the directory the run will use.** Launch on `mixed_fixture`, `RET` into `sub/index.js`, dispatch `C-c c`; assert contents are `""` and `context().kind == "node"`. -*Falsifier:* any rule that is not "detect from the resolved cwd" — take -the outermost marker, or the launch directory, or the process cwd — all -yield `cargo build`. -This is the pin that carries §3.1's whole claim. +*Falsifier:* a rule that derives the kind from anything other than the +resolved cwd. The plausible one is the **launch directory** — Stage 1a +made it prominent, and `mixed_fixture` was launched on the Cargo root, so +that rule yields `cargo build` here. So does deriving it from the process +cwd. +*What it does not catch, stated because rev 1 claimed otherwise:* +re-detecting from `project_root_of_active()`'s answer. That helper +already returns the **innermost** root, so detecting from it yields +`node` again and this pin stays green. Rev 1 named that mutation as the +falsifier; it is not one. -**N5 — `context()` is total in a launched session.** +**N6 — `context()` is total in a launched session.** Property, not a constant, because the value is environment-dependent (§2.7): after a launch, `context().cwd` is non-nil, and `context().kind` equals `pmacs.project.detect(context().cwd)`'s kind (both nil, or both @@ -461,9 +582,16 @@ interaction island §6 of COHERENCE is about. What this stage does guarantee is that the failure is **coherent**: the suggestion describes the directory the command will run in, whatever that directory turns out to be. The user is never offered `cargo build` -for a directory with no `Cargo.toml`. +for a directory with **no detected Cargo project**. -N5 pins the property; it deliberately does not pin the value. +That wording is load-bearing and rev 1 got it wrong. "No `Cargo.toml`" +would have been false on this design's own terms: a Cargo *workspace +subdirectory* contains no `Cargo.toml`, is correctly detected as `rust` +by the ancestor walk, and correctly receives `cargo build` — which cargo +itself runs happily from a subdirectory. The predicate is detection, not +the presence of a file in that one directory (§3.1). + +N6 pins the property; it deliberately does not pin the value. ### 6.2 The default is per-kind, not configurable through the registry From 6bee82c1a5a85547314596d870926eebdc604145 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 21:13:12 -0400 Subject: [PATCH 3/6] =?UTF-8?q?feat(compile):=20make=20building=20discover?= =?UTF-8?q?able=20=E2=80=94=20journey=20step=209?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `docs/journey-stage1b1-compile-defaults-framing.md` (approved at revision 2). Lua, tests and docs; no Rust change and no protocol change. `C-c c` now runs `compile.run`, and the first prompt is prefilled from the detected project kind through `pmacs.compile.defaults` — seeded `rust = "cargo build"` and extensible from `init.lua`. `_last` still wins, so a session that has compiled keeps its own command. The prompt CAPTURES its directory rather than re-resolving it. Sharing one resolver between the prompt and the run is necessary and not sufficient: `pmacs.minibuffer.read` is asynchronous and nothing freezes the active window while a prompt is open, so two calls to the same resolver at two different moments are still two different answers — the user could be offered `cargo build` for A and handed a run in B by clicking away mid-prompt. This is Journey Stage 1a's `commit_to` discipline on a smaller seam. `pmacs.compile.defaults` is public and assignable, so the lookup is guarded: a throwing `__index`, a non-string entry and a non-table replacement all degrade to the pre-stage empty prompt and never prevent compiling. Only `rust` ships seeded. Rust has one answer; npm/yarn/pnpm, make/cmake, and `go build` versus `go test` do not, and a wrong prefill costs more than an empty one. Adds eight step-9 rows to the journey ratchet and five module pins to the compile suite. Corrects `COHERENCE.md`, which named a `ProjectKind::Cargo` that does not exist — the variant is `Rust`, line 77 is its doc comment, and Lua only ever sees the tag string "rust". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- COHERENCE.md | 45 ++-- builtin/runtime/compile.lua | 87 +++++++- docs/active-work.md | 14 +- docs/agent-handoff.md | 42 ++++ docs/journey-stage1a-framing.md | 6 + docs/keybindings.md | 8 +- tests/compile_mode_acceptance.rs | 103 +++++++++ tests/journey_acceptance.rs | 371 +++++++++++++++++++++++++++++++ 8 files changed, 653 insertions(+), 23 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index dd4a16e..5866238 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -378,7 +378,7 @@ Full verdict table: | 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose | | 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 | **Partial** | `M-x compile.run` works, defaults cwd to detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) | +| 9 | Build / test | **Works** | Journey Stage 1b-1: `C-c c` runs `compile.run`, and the first prompt is prefilled from the detected project kind (`pmacs.compile.defaults`, seeded `rust = "cargo build"`). 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 | | 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first | | 11 | See background work | **Works but undiscoverable** | `*workers*` view via `M-x editor.list-workers`; `C-c C-k` cancel-at-point. No keybinding, no statusline spinner/progress indicator anywhere (§9) | | 12 | Close + restore | **Partial** | Per-file cursor+scroll (saveplace), recent files, minibuffer history, autosave recovery all restore zero-config. Open-buffer set and window layout do **not**: desktop-save is opt-in (`pmacs.session.desktop_mode(true)`) *and* a documented no-op under a daemon (`src/desktop.rs:323-326`, `:353-356`, Q#DS9) | @@ -388,12 +388,14 @@ A journey observation worth keeping verbatim from the audit: C-M-s` opens all folds, while opening a file, opening a terminal, and running a build have no bindings at all. -Two of that observation's three examples have since been answered — -opening a file by `C-x C-f` (#162) and opening a terminal by `C-c t` -(#173). **Running a build still has no binding**, and the underlying -inversion is a standing bias in how new work gets bound, not three -isolated omissions: the quote stays as written because it names the -pattern, and the pattern is not retired until step 9 is. +All three of that observation's examples have now been answered — +opening a file by `C-x C-f` (#162), opening a terminal by `C-c t` +(#173), and running a build by `C-c c` (Journey Stage 1b-1). **The quote +stays as written**: it names a standing bias in how new work gets bound, +not three isolated omissions, and three fixes do not retire a bias. What +has changed is that the bias no longer has an uncontested example in the +golden journey — the next new surface that ships without a binding is +evidence the pattern is live again, and should be read that way. --- @@ -1542,14 +1544,17 @@ 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 (§1.2); a -compile keybinding + `cargo build`/`test` default from the existing -`ProjectKind::Cargo`; a terminal keybinding (**done**: `C-c t`, #173); a -welcome buffer. The journey acceptance suite (§19) is the ratchet that +compile keybinding + `cargo build`/`test` default (**done**: Journey +Stage 1b-1, from the existing `ProjectKind::Rust` — **not** `Cargo`, see +§24); a terminal keybinding (**done**: `C-c t`, #173); a welcome +buffer. 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. -Journey Stage 1b is the named remainder: the compile binding + Cargo -defaults, LSP spawn guidance, and the welcome buffer. +Journey Stage 1b is the named remainder, and it splits: **1b-1 — the +compile binding + project-kind defaults — is done**; 1b-2 (LSP spawn +guidance, step 6) and 1b-3 (the welcome buffer, step 4) remain. The +ratchet is now seeded with steps 2, 3, 5 and 9. ### Priority 2: Make workspace and location explicit @@ -1620,8 +1625,10 @@ implementation — this list is direction, not commitment): `resolve_target_buffer` unification, the destination-scope substrate, and the first journey acceptance suite. It routes `pmacs .` into #165's dired buffer rather than growing a second directory surface. - **Stage 1b — remaining**: compile defaults, LSP-failure surfacing, - bindings, welcome buffer. + **Stage 1b-1 — landed**: the compile binding and project-kind + defaults, with the prompt capturing its directory rather than + re-resolving it at accept time. **Stage 1b-2 / 1b-3 — remaining**: + LSP-failure surfacing, welcome buffer. 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 @@ -1713,6 +1720,16 @@ available without being imposed. Found during the audit; fix opportunistically, ideally before this document is wired into CLAUDE.md/AGENTS.md as required reading: +- **This document named a `ProjectKind` variant that does not exist**, + in two places: §2's step-9 row and §20 Priority 1 both said + "`ProjectKind::Cargo` existing (`src/project.rs:77`)". Line 77 is the + *doc comment*; the variant on line 78 is **`ProjectKind::Rust`**, + produced by the `Cargo.toml` marker. The audit read the comment and + named the comment. Corrected by Journey Stage 1b-1 — which also + establishes that the Lua side never sees the variant at all: + `pmacs.project.detect` returns the **tag string** `"rust"`. Kept here + rather than silently fixed, because a wrong type name in the document + work is evaluated against costs a scout a real detour. - `docs/keybindings.md` — every `src/editor.rs` line citation in §3 is stale by ~250–1000 lines despite a "last verified @ `f8096ff` (2026-07-20)" stamp; its shadow list also omits the terminal `C-c` diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua index 544c1d7..e6e85d2 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -620,6 +620,60 @@ local function daemon_working_directory() return nil end +-- Where a run started right now would execute. Extracted from the +-- inline expression that used to live in `pmacs.compile.run`, because +-- the interactive command needs the SAME answer to build its prompt: +-- a suggestion computed from a different rule than the run obeys can +-- name a toolchain that is not at the directory the command executes +-- in (journey Stage 1b-1 framing §3.1). +local function resolve_cwd(explicit) + return explicit or project_root_of_active() or daemon_working_directory() +end + +--- Default compile command per detected project kind, keyed by the tag +--- `pmacs.project.detect` returns. Assign into this table from +--- `init.lua` to add or override one: +--- +--- pmacs.compile.defaults.go = "go build ./..." +--- +--- Only `rust` ships seeded, and that is a decision rather than an +--- omission: Rust has one answer, while npm/yarn/pnpm, make/cmake, and +--- `go build` versus `go test` do not. A wrong prefill costs more than +--- an empty one — the user must delete it before typing. +pmacs.compile.defaults = { rust = "cargo build" } + +-- Read `defaults[kind]` defensively. The table is public and +-- assignable, so a metatable with a throwing `__index`, a non-string +-- entry, or a non-table replacement all have to be survivable — the +-- same discipline `validated_rules` keeps for a hostile rule container +-- (round-2 finding 3). A broken `defaults` degrades to the pre-stage +-- empty prompt; it never prevents compiling. +local function default_command_for(kind) + if type(kind) ~= "string" then return nil end + local ok, value = pcall(function() return pmacs.compile.defaults[kind] end) + if ok and type(value) == "string" and #value > 0 then return value end + return nil +end + +--- Where the next compile would run, and what kind of project is +--- detected *from that directory*. Public getter (per API +--- conventions): `{ cwd = string|nil, kind = string|nil }`. +--- +--- `kind` is always `detect(cwd)`, one rule regardless of which branch +--- produced `cwd`. Note that is not the same as "`cwd` is a project +--- root": a Cargo workspace subdirectory contains no `Cargo.toml`, is +--- detected as `rust` by the ancestor walk, and is a perfectly good +--- place to run `cargo build`. +function pmacs.compile.context(explicit_cwd) + local cwd = resolve_cwd(explicit_cwd) + local kind = nil + if cwd then + local ok, proj = pcall(pmacs.project.detect, cwd) + if ok and proj and type(proj.kind) == "string" then kind = proj.kind end + end + return { cwd = cwd, kind = kind } +end + local function format_exit_marker(label, ev) if ev.kind == "exited" then return string.format("\n[%s exited with code %d]\n", label, ev.code or 0) @@ -762,7 +816,7 @@ local function start_run(slot, cmdline, opts) if cur and not pmacs.compile.is_generated_buffer(cur) then slot.prev = cur end - local cwd = opts.cwd or project_root_of_active() or daemon_working_directory() + local cwd = resolve_cwd(opts.cwd) -- Supersede (Q#CM9): terminate the old group and tombstone its -- pump entry; its terminal event still drives forget. @@ -1132,13 +1186,27 @@ pmacs.command.define { description = "Compile: run a command in a streaming *compilation* buffer (M-x compile).", fn = function() local last = pmacs.compile._last + -- Captured ONCE, here. `pmacs.minibuffer.read` is asynchronous and + -- nothing freezes the active window while a prompt is open, so + -- re-resolving inside `on_accept` would let the prompt offer + -- `cargo build` for A and execute in B. Sharing the resolver is + -- necessary and not sufficient; the resolution has to be captured. + -- This is Journey Stage 1a's `commit_to` discipline on a smaller + -- seam — the mechanism differs, the failure prevented is the same. + local ctx = pmacs.compile.context() pmacs.minibuffer.read { prompt = "Compile command: ", history = "compile", - initial = last and last.cmdline or "", + -- `_last` still wins: a user who ran `cargo test` once gets it + -- back rather than being reset to the project default. + initial = last and last.cmdline or default_command_for(ctx.kind) or "", on_accept = function(cmdline) if cmdline == nil or cmdline == "" then return end - pmacs.compile.run(cmdline) + -- `ctx.cwd` passes through verbatim, INCLUDING nil: a nil cwd + -- means every resolution step failed, and the header renders + -- "(unknown)" exactly as before. Re-resolving here would + -- reintroduce the drift for the case least able to tolerate it. + pmacs.compile.run(cmdline, { cwd = ctx.cwd }) end, } end, @@ -1186,3 +1254,16 @@ pmacs.keymap.bind { scope = "global", sequence = "M-g n", command = "error.next" pmacs.keymap.bind { scope = "global", sequence = "M-g p", command = "error.previous" } pmacs.keymap.bind { scope = "global", sequence = "C-x `", command = "error.next" } pmacs.keymap.bind { scope = "global", sequence = "M-!", command = "shell.command" } +-- Journey step 9 (COHERENCE §2): running a build had no binding at all, +-- while `C-c @ C-M-s` opened all folds. `C-c c` is free, sits under the +-- established `C-c` prefix, and does not collide with CUA copy (that is +-- `M-w`). Bound here rather than in `default.lua` because a runtime +-- module owns its own global keys — `terminal.lua` binds `C-c t`, +-- `lsp.lua` binds `C-c o`. +-- +-- Two inherited reachability limits, both pre-existing: inside a +-- terminal window `C-c` is consumed as the escape key, and the repl +-- package binds `C-c` at buffer scope. `M-x compile.run` still works in +-- both. `compile.recompile` gets no global chord — `g` in +-- `*compilation*` already covers rerun. +pmacs.keymap.bind { scope = "global", sequence = "C-c c", command = "compile.run" } diff --git a/docs/active-work.md b/docs/active-work.md index 9c9b97d..25a13ff 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -253,13 +253,19 @@ 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) — STAGE 1a MERGED; STAGE 1b-1 FRAMING OPEN, revision 2 +## Journey lane (P1) — STAGE 1a MERGED; STAGE 1b-1 IMPLEMENTED, PR OPEN - **Branch `journey-stage1b1-compile-defaults`**, worktree `../pmacs-journey-1b1`, based on `githubsucks/main` @ `22df6ab`. - **Framing only; no code, no PR yet.** - `docs/journey-stage1b1-compile-defaults-framing.md` revision 2, one - review round closed (two blocking, two major, all accepted). + `docs/journey-stage1b1-compile-defaults-framing.md` revision 2, + approved after one review round (two blocking, two major, all + accepted). **Implemented; PR open.** +- **Bites found two vacuous pins of my own.** The nested-project pin + passed with the keybinding removed, because `minibuffer.contents()` is + `""` both for an empty prefill and for no minibuffer at all — `""` + compared with `""`. And the hostile-`defaults` pin called + `compile.run` directly, so it never consulted `defaults` and passed + with the guard removed. Both now assert their precondition. - **Round 1's blocking finding is the Stage 1a lesson repeating.** Sharing one cwd resolver between the prompt and the run is *not* enough: `pmacs.minibuffer.read` is async, the active window can change diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index b4629f5..a361efe 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -104,6 +104,48 @@ commands, read `docs/active-work.md` immediately after this file. interaction islands added, config-registry adoption, background-work attribution. Its §2 grades the golden journey; **Journey Stage 1a moved that grade off "broken at step 3"** — see the arc bullet below. +- **Journey arc (P1) — Stage 1b-1 LANDED** (`docs/journey-stage1b1-compile-defaults-framing.md`). + Journey step 9 moves **Partial → Works**: `C-c c` runs `compile.run`, + and the first prompt is prefilled from the detected project kind via + `pmacs.compile.defaults` (seeded `rust = "cargo build"`, extensible + from `init.lua`). Lua, tests and docs; no Rust change, no protocol + change. The ratchet now carries steps 2, 3, 5 and **9**. + - **Sharing a resolver is not capturing one.** `pmacs.minibuffer.read` + is asynchronous and nothing freezes the active window while a prompt + is open, so having the prompt and `compile.run` call the *same* cwd + resolver still let them disagree: the user could be offered + `cargo build` for A and given a run in B by clicking away + mid-prompt. The interactive command captures `pmacs.compile.context()` + once and passes its `cwd` through to the run. **This is Stage 1a's + `commit_to` lesson on a smaller seam** — capture at request time, + never re-derive when the async work lands. Review found it; the + first framing had the weaker design and said it was sufficient. + - **A pin that never crosses the accept boundary cannot see that + class of bug.** Every originally-proposed pin compared values the + prompt and the resolver already agreed on, so the defect above + passed all of them. Two pins now accept the prompt and observe a + real process. + - **`ProjectKind::Cargo` does not exist** — `COHERENCE.md` named it + twice, citing `src/project.rs:77`, which is the *doc comment*; the + variant on line 78 is `ProjectKind::Rust`. Lua never sees the + variant anyway: `pmacs.project.detect` returns the tag string + `"rust"`. Corrected in both COHERENCE sites and recorded in its §24. + - **The compile fallback cwd is the process cwd, which under `cargo + test` is the pmacs repo — itself a Cargo project.** Any pin + asserting the *absence* of a Cargo suggestion that reaches the + fallback reports pmacs's own `Cargo.toml`. Bite D confirmed it: with + the kind derived from the process cwd, the plain-fixture prefill pin + still passed and only the nested-project pin caught it. + `set_search_boundary` is no defence — it clamps only a walk starting + *below* the boundary. + - **Named limitation, deliberately unfixed:** after `pmacs ` the + active buffer is dired's and **pathless** (`pmacs.buffer.create` + assigns no path; dired compensates through its own module-local + `handle_for_buffer`), so the cwd falls through to the process cwd. + Launched from elsewhere that is the wrong directory. The fix is + §8's execution-location model, not a reach into dired's private + table. What is guaranteed is that the failure stays *coherent*: the + suggestion always describes the directory the run will use. - **Journey arc (P1) — Stage 1a LANDED** (`docs/journey-stage1a-framing.md`). `pmacs .` opens a directory instead of exiting 1, on **one** path: `resolve_target_buffer` gained a diff --git a/docs/journey-stage1a-framing.md b/docs/journey-stage1a-framing.md index ce5589f..59691db 100644 --- a/docs/journey-stage1a-framing.md +++ b/docs/journey-stage1a-framing.md @@ -230,6 +230,12 @@ Not in 1a — Stage 1b: a compile keybinding and `cargo build`/`test` defaults from the existing `ProjectKind::Cargo`, LSP spawn-failure guidance (§1.2), a welcome buffer. +*(Correction, added by Stage 1b-1: there is no `ProjectKind::Cargo`. The +variant is `ProjectKind::Rust`; this line inherited the error from +`COHERENCE.md` §20, now recorded in its §24. Left in place rather than +rewritten — this document records what was planned — but flagged so the +wrong type name does not survive by being quoted.)* + --- ## 2. Ground truth (scouted 2026-07-26, `main` @ `d400f30`; re-verified rev 4) diff --git a/docs/keybindings.md b/docs/keybindings.md index b94bd63..922692f 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -187,9 +187,13 @@ Source: `builtin/runtime/compile.lua`. | `M-g n` / `M-g p` | `error.next` / `error.previous` — compile/grep errors when that source has claimed navigation, otherwise LSP diagnostics | | `` C-x ` `` | `error.next` | | `M-!` | `shell.command` — asynchronous output in `*shell-command*` | +| `C-c c` | `compile.run` — prompts, prefilled from the detected project kind | -`compile.run` and `compile.recompile` are available through `M-x`; no -global key is assigned to them. +`compile.recompile` is available through `M-x`, and through `g` inside +`*compilation*`; no global key is assigned to it. `C-c c` is unreachable +from inside a terminal window (`C-c` is consumed as the escape key) and +inside a repl buffer (which binds `C-c` at buffer scope); `M-x +compile.run` still works in both. ## 2. Buffer-local panel keymaps diff --git a/tests/compile_mode_acceptance.rs b/tests/compile_mode_acceptance.rs index 377b094..e7f21e0 100644 --- a/tests/compile_mode_acceptance.rs +++ b/tests/compile_mode_acceptance.rs @@ -2750,3 +2750,106 @@ fn r5f3_tracked_line_start_matches_the_scan_across_transitions() { "every rewind lands at the tracked line start; buffer:\n{text:?}" ); } + +// --------------------------------------------------------------------------- +// Journey Stage 1b-1 — `pmacs.compile.defaults` is public and assignable +// +// The table is user-writable, so a metatable with a throwing `__index`, a +// non-string entry, and a non-table replacement all have to be +// survivable — the same discipline `validated_rules` keeps for a hostile +// rule container. A broken `defaults` must degrade to the pre-stage empty +// prompt; it must never prevent compiling. +// --------------------------------------------------------------------------- + +/// Open the compile prompt and return what it offered. +fn compile_prompt_initial(s: &EditorState) -> String { + exec(s, "pmacs.command.invoke('compile.run')"); + assert!( + eval::(s, "return pmacs.minibuffer.is_active()"), + "compile.run must open a prompt even with a hostile defaults table" + ); + eval(s, "return pmacs.minibuffer.contents()") +} + +#[test] +fn j1b1_a_non_table_defaults_degrades_to_an_empty_prompt() { + let s = editor(); + exec(&s, "pmacs.compile.defaults = 42"); + assert_eq!(compile_prompt_initial(&s), ""); +} + +#[test] +fn j1b1_a_raising_defaults_index_degrades_to_an_empty_prompt() { + let s = editor(); + exec( + &s, + "pmacs.compile.defaults = setmetatable({}, { + __index = function() error('hostile') end, + })", + ); + assert_eq!(compile_prompt_initial(&s), ""); +} + +#[test] +fn j1b1_a_non_string_default_is_ignored() { + let s = editor(); + exec(&s, "pmacs.compile.defaults = { rust = {}, }"); + assert_eq!(compile_prompt_initial(&s), ""); +} + +#[test] +fn j1b1_a_hostile_defaults_still_lets_a_typed_command_run() { + // The consequence that matters: degradation must not cost the user + // the ability to compile. Asserts output produced, not a property + // preserved. + let mut s = editor(); + exec( + &s, + "pmacs.compile.defaults = setmetatable({}, { + __index = function() error('hostile') end, + })", + ); + // Through the INTERACTIVE command, so the hostile lookup actually + // happens. Calling `pmacs.compile.run` directly never consults + // `defaults` at all, which made an earlier draft of this pin vacuous + // — it passed with the guard removed. + exec(&s, "pmacs.command.invoke('compile.run')"); + assert!( + eval::(&s, "return pmacs.minibuffer.is_active()"), + "the prompt must still open" + ); + exec(&s, "pmacs.minibuffer.set_contents('echo still-compiles')"); + exec(&s, "pmacs.minibuffer.accept()"); + assert!( + pump_until(&mut s, 10_000, |s| { + compilation_text(s).contains("still-compiles") + }), + "a broken defaults table must not prevent compiling; got:\n{}", + compilation_text(&s) + ); +} + +#[test] +fn j1b1_context_reports_the_cwd_a_run_would_use() { + // `pmacs.compile.context` and `pmacs.compile.run` must answer from + // one resolution — an explicit cwd is honoured by both, and the kind + // is detected from that same directory. + let s = editor(); + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("Cargo.toml"), b"[package]\nname=\"x\"\n").expect("write"); + exec( + &s, + &format!( + "pmacs.project.set_search_boundary({:?})", + dir.path().display().to_string() + ), + ); + let kind: String = eval( + &s, + &format!( + "return pmacs.compile.context({:?}).kind", + dir.path().display().to_string() + ), + ); + assert_eq!(kind, "rust", "the kind is detected from the given cwd"); +} diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs index 7ab36c0..9e7cb1c 100644 --- a/tests/journey_acceptance.rs +++ b/tests/journey_acceptance.rs @@ -1297,3 +1297,374 @@ fn preservation_display_file_still_refuses_a_directory() { "a refused display_file must not create a buffer" ); } + +// --------------------------------------------------------------------------- +// Step 9 — build or test the project (Journey Stage 1b-1) +// +// `COHERENCE.md` §2 graded this **Partial**: `M-x compile.run` worked but +// had no keybinding, an empty first prompt, and no `cargo build` +// suggestion. These rows are the ratchet for the three. +// +// The trap these fixtures are built around: compile's last-resort cwd is +// `std::env::current_dir()` evaluated at call time, which under `cargo +// test` is the **pmacs repo root — itself a Cargo project** +// (`compile_mode_acceptance.rs` pins exactly that). So any pin asserting +// the *absence* of a Cargo suggestion must be shaped so the fallback is +// never consulted: each fixture carries its own marker, and +// `project_root_of_active()` therefore answers from inside the fixture. +// `set_search_boundary` is not the defence — it clamps only a walk that +// starts below the boundary, and the fallback's walk starts at the repo. +// --------------------------------------------------------------------------- + +fn ctrl(s: &mut EditorState, c: char) { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(c), KeyModifiers::CONTROL), + ); +} + +/// Open the compile prompt through the real `C-c c` binding. +fn press_compile_chord(s: &mut EditorState) { + ctrl(s, 'c'); + type_char(s, 'c'); +} + +/// A Cargo project a journey can plausibly be run against. +fn cargo_project() -> TempDir { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write( + td.path().join("Cargo.toml"), + b"[package]\nname = \"journey-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .expect("write Cargo.toml"); + std::fs::create_dir_all(td.path().join("src")).expect("mkdir src"); + std::fs::write(td.path().join("src/main.rs"), b"fn main() {}\n").expect("write main.rs"); + td +} + +/// Clamp detection to the fixture so a stray marker above the tempdir +/// (a developer's `/tmp/.git`) cannot leak in. +fn bound_detection_to(s: &EditorState, dir: &Path) { + exec( + s, + &format!( + "pmacs.project.set_search_boundary({:?})", + dir.display().to_string() + ), + ); +} + +fn minibuffer_active(s: &EditorState) -> bool { + eval(s, "return pmacs.minibuffer.is_active()") +} + +fn minibuffer_contents(s: &EditorState) -> String { + eval(s, "return pmacs.minibuffer.contents()") +} + +fn named_text(s: &EditorState, name: &str) -> String { + eval( + s, + &format!( + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == {name:?} then + return id:slice(0, id:len()) + end + end + return "" + "# + ), + ) +} + +/// Drive frames until `pred` holds, pumping the process supervisor as +/// well as the async runtime — a compile run is a spawned child. +fn pump_processes_until( + s: &mut EditorState, + timeout_ms: u64, + mut pred: impl FnMut(&EditorState) -> bool, +) -> bool { + let stop = Instant::now() + Duration::from_millis(timeout_ms); + loop { + if pred(s) { + return true; + } + if Instant::now() >= stop { + return false; + } + s.tick_processes(); + s.tick_async(); + std::thread::sleep(Duration::from_millis(5)); + } +} + +/// Walk the journey to the point where a build would be run: launch on +/// the directory, then `RET` on `name` to open a file. +fn walk_to_open_file(dir: &Path, name: &str) -> EditorState { + let mut s = launch(dir); + bound_detection_to(&s, dir); + let line = line_of(&s, name); + exec(&s, &format!("pmacs.editor.move_to_line({line})")); + press(&mut s, KeyCode::Enter); + pump(&mut s); + assert_eq!( + active_name(&s), + dir.join(name).display().to_string(), + "precondition: the walk must actually open the file, or every pin \ + below is really testing the dired buffer" + ); + s +} + +/// **N** — `C-c c` reaches `compile.run`. Separate from N2 because a +/// prefill assertion alone stays green if the binding is removed and the +/// prompt is opened another way — and the binding is the thing +/// `COHERENCE.md` says is missing. +#[test] +fn journey_step9_the_compile_chord_opens_the_prompt() { + let td = cargo_project(); + let mut s = walk_to_open_file(td.path(), "Cargo.toml"); + press_compile_chord(&mut s); + assert!( + minibuffer_active(&s), + "C-c c must open the compile prompt; active buffer is {}", + active_name(&s) + ); +} + +/// **N** — the prompt is prefilled from the detected project kind. +#[test] +fn journey_step9_the_prompt_is_prefilled_for_a_cargo_project() { + let td = cargo_project(); + let mut s = walk_to_open_file(td.path(), "Cargo.toml"); + press_compile_chord(&mut s); + assert_eq!( + minibuffer_contents(&s), + "cargo build", + "a Rust project's first compile prompt must offer its build command" + ); +} + +/// **N** — the directory the prompt captured survives a window switch. +/// +/// The prompt is opened against A, the active buffer then moves to an +/// unrelated directory B, and only then is the command accepted. Both +/// readings — the header pmacs writes and the shell's own `pwd` — must +/// say A. Falsified by dropping `{ cwd = ctx.cwd }` from `on_accept`, +/// which is what re-resolving at accept time looks like. +#[test] +fn journey_step9_the_prompt_runs_in_the_directory_it_captured() { + let a = cargo_project(); + let b = project(); + let mut s = walk_to_open_file(a.path(), "Cargo.toml"); + press_compile_chord(&mut s); + assert!(minibuffer_active(&s), "prompt must be open against A"); + + // Move the active buffer to B while the prompt waits for input. + exec( + &s, + &format!( + "pmacs.window.display_file({:?})", + b.path().join("alpha.txt").display().to_string() + ), + ); + pump(&mut s); + + // Accept a cheap command: the subject is the directory, so a fast + // one keeps the failure message about the directory. Only the + // editing of an already-open prompt is short-circuited; the prompt + // itself was opened through the real chord. + exec(&s, "pmacs.minibuffer.set_contents('pwd')"); + press(&mut s, KeyCode::Enter); + let finished = pump_processes_until(&mut s, 10_000, |s| { + named_text(s, "*compilation*").contains("exited") + }); + assert!(finished, "the accepted run must finish"); + + let text = named_text(&s, "*compilation*"); + let a_path = canon(a.path()); + assert!( + text.contains(&format!("Directory: {a_path}")), + "the header must name the directory the prompt captured, not the newly active one;\n{text}" + ); + assert!( + text.contains(&a_path), + "and `pwd` must agree with the header;\n{text}" + ); +} + +/// **N** — the offered command runs, in the offered directory. +/// +/// Every other pin here compares values the prompt and the resolver +/// already agree on. A wrong directory inside `on_accept` passes all of +/// them. This one accepts what was offered, unedited, and observes a +/// real process. +#[test] +fn journey_step9_the_offered_command_builds_the_project() { + if !binary_available("cargo") { + if std::env::var_os("PMACS_REQUIRE_CARGO_BUILD").is_some() { + panic!("PMACS_REQUIRE_CARGO_BUILD is set but `cargo` is not on PATH"); + } + eprintln!("skipping: `cargo` is not on PATH"); + return; + } + let td = cargo_project(); + let mut s = walk_to_open_file(td.path(), "Cargo.toml"); + press_compile_chord(&mut s); + assert_eq!( + minibuffer_contents(&s), + "cargo build", + "precondition: the prompt offers the build command" + ); + + // Accept exactly what was offered — no editing. + press(&mut s, KeyCode::Enter); + let finished = pump_processes_until(&mut s, 120_000, |s| { + named_text(s, "*compilation*").contains("exited") + }); + assert!(finished, "the offered build must finish"); + + let text = named_text(&s, "*compilation*"); + assert!( + text.contains(&format!("Directory: {}", canon(td.path()))), + "the build runs in the detected project root;\n{text}" + ); + assert!( + text.contains("[compile exited with code 0]"), + "and the command that was offered is one that actually runs;\n{text}" + ); + // Positive control on the run itself. A clean exit alone does not + // prove cargo compiled anything — this names the fixture crate, so + // the pin fails if the offered command ever stops being a build. + assert!( + text.contains("journey-fixture"), + "cargo must actually have built the fixture crate;\n{text}" + ); +} + +/// **N** — the suggestion follows the directory the run will use. +/// +/// A Node project nested inside a Cargo one, opened at the *inner* file. +/// Falsified by deriving the kind from the launch directory (the Cargo +/// root here) or from the process cwd — both yield `cargo build`. +/// +/// It does **not** catch re-detecting from `project_root_of_active()`'s +/// answer: that helper already returns the innermost root, so detecting +/// from it yields `node` again and this pin stays green. +#[test] +fn journey_step9_a_nested_project_gets_its_own_kind_not_the_outer_one() { + let outer = cargo_project(); + let sub = outer.path().join("sub"); + std::fs::create_dir_all(&sub).expect("mkdir sub"); + std::fs::write(sub.join("package.json"), b"{ \"name\": \"inner\" }\n").expect("write pkg"); + std::fs::write(sub.join("index.js"), b"console.log(1)\n").expect("write index.js"); + + let mut s = walk_to_open_file(&sub, "index.js"); + press_compile_chord(&mut s); + // `minibuffer_contents` is "" both for an empty prefill and for no + // minibuffer at all, so the emptiness assertion below is vacuous + // without this. Bite A caught it: with the binding removed this pin + // stayed green while six others failed. + assert!( + minibuffer_active(&s), + "precondition: the prompt is actually open" + ); + assert_eq!( + eval::(&s, "return pmacs.compile.context().kind"), + "node", + "the kind is detected from the resolved cwd, not the launch directory" + ); + assert_eq!( + minibuffer_contents(&s), + "", + "and `node` has no seeded default, so nothing is offered" + ); +} + +/// **N** — `context()` is total in a launched session. +/// +/// Asserted with the pathless dired buffer active, so the fallback +/// branch is the one under test. It pins a *property*, deliberately not +/// a value: which directory the fallback resolves to is the test +/// runner's cwd and pinning it would pin the environment. +#[test] +fn journey_step9_the_compile_context_is_total_even_with_no_file_open() { + let td = cargo_project(); + let s = launch(td.path()); + assert!( + active_name(&s).starts_with("*dired:"), + "precondition: the pathless dired buffer is active" + ); + assert!( + eval::(&s, "return pmacs.compile.context().cwd ~= nil"), + "a launched session always has somewhere to run a build" + ); + assert!( + eval::( + &s, + "local c = pmacs.compile.context() + local ok, p = pcall(pmacs.project.detect, c.cwd) + local detected = (ok and p) and p.kind or nil + return c.kind == detected" + ), + "and its kind is exactly what detection answers for that cwd" + ); +} + +/// **P** — `_last` still outranks the kind default. +/// +/// Green on the pre-image (there was no default). The targeted mutation +/// is reordering the precedence chain to put `defaults[kind]` first. +#[test] +fn journey_step9_preservation_the_last_command_outranks_the_default() { + let td = cargo_project(); + let mut s = walk_to_open_file(td.path(), "Cargo.toml"); + exec(&s, "pmacs.compile.run('true')"); + let finished = pump_processes_until(&mut s, 10_000, |s| { + named_text(s, "*compilation*").contains("exited") + }); + assert!(finished, "precondition: a run completed and set _last"); + + press_compile_chord(&mut s); + assert_eq!( + minibuffer_contents(&s), + "true", + "a session that has compiled keeps its own command, not the project default" + ); +} + +/// **P** — the compile keys that already existed still dispatch. +/// +/// Targeted mutation: writing the new binding as an unbind+bind pair +/// over one of these sequences. +#[test] +fn journey_step9_preservation_the_existing_compile_bindings_survive() { + let td = cargo_project(); + let s = walk_to_open_file(td.path(), "Cargo.toml"); + for (sequence, command) in [ + ("M-g n", "error.next"), + ("M-g p", "error.previous"), + ("C-x `", "error.next"), + ("M-!", "shell.command"), + ("C-c c", "compile.run"), + ] { + let bound: String = eval( + &s, + &format!("return pmacs.keymap.lookup({sequence:?}).command"), + ); + assert_eq!( + bound, command, + "{sequence} must still dispatch to {command}" + ); + } +} + +/// True when `name` resolves on PATH — the fixture-dependency gate the +/// process suite uses, so a missing binary skips rather than fails. +fn binary_available(name: &str) -> bool { + std::process::Command::new("which") + .arg(name) + .output() + .is_ok_and(|o| o.status.success()) +} From 52d7e5e1eb3574d40a4666e577364fbbea36d913 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 21:16:29 -0400 Subject: [PATCH 4/6] style(journey): satisfy clippy::manual_assert in the arming gate `-D warnings` with pedantic rejects an `if`-then-`panic!`. Same semantics: the arming variable only makes a missing binary fatal, it never decides whether the pin runs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- tests/journey_acceptance.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs index 9e7cb1c..c7b0569 100644 --- a/tests/journey_acceptance.rs +++ b/tests/journey_acceptance.rs @@ -1503,9 +1503,10 @@ fn journey_step9_the_prompt_runs_in_the_directory_it_captured() { #[test] fn journey_step9_the_offered_command_builds_the_project() { if !binary_available("cargo") { - if std::env::var_os("PMACS_REQUIRE_CARGO_BUILD").is_some() { - panic!("PMACS_REQUIRE_CARGO_BUILD is set but `cargo` is not on PATH"); - } + assert!( + std::env::var_os("PMACS_REQUIRE_CARGO_BUILD").is_none(), + "PMACS_REQUIRE_CARGO_BUILD is set but `cargo` is not on PATH" + ); eprintln!("skipping: `cargo` is not on PATH"); return; } From 0b1716b5cdd35beac300eb6d81607b7c524963d4 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 21:38:47 -0400 Subject: [PATCH 5/6] fix(journey): canonicalize the compile-directory expectation; unflip the grades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 on #203, two blocking findings, both accepted. The compile-directory assertions used the suite's lexical `canon()`, but `pmacs.project.detect` canonicalizes before walking (`canonicalize_or_passthrough`, `src/project.rs:509-511`), so the compile cwd is filesystem-canonical. On macOS `/var` is a symlink to `/private/var` and the two spellings disagree — both macOS legs failed while Ubuntu, where `/tmp` is not a symlink, stayed green. Fixed with a `detected_root()` expectation, and pinned by a fixture that launches through an explicit **symlink** so lexical and canonical paths disagree on every platform. That matters more than the fix: the original bite ran only on Linux, where nothing could make the two differ, so no amount of local mutation testing would have caught this. The new pin is the only one that goes red when the lexical expectation is restored. Second: `COHERENCE.md` §2's step-9 row was flipped to **Works** and the handoff said Stage 1b-1 was **LANDED**, while this PR is open. §25 is explicit that grades change only with landed evidence, never aspirationally. Both now describe the real state — the row stays Partial and names #203 as the open PR that closes it, §20 and the arc list say "in flight", and the handoff says IMPLEMENTED with the PR number. The flip is not dropped, it is owned: the active-work lane records the four places to change on merge, because an unowned doc flip is exactly how this ledger's drift starts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- COHERENCE.md | 33 ++++++++-------- docs/active-work.md | 18 +++++++++ docs/agent-handoff.md | 7 +++- tests/journey_acceptance.rs | 77 ++++++++++++++++++++++++++++++++++++- 4 files changed, 115 insertions(+), 20 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 5866238..2154a87 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -378,7 +378,7 @@ Full verdict table: | 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose | | 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: `C-c c` runs `compile.run`, and the first prompt is prefilled from the detected project kind (`pmacs.compile.defaults`, seeded `rust = "cargo build"`). 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 | +| 9 | Build / test | **Partial** | `M-x compile.run` works, defaults cwd to the detected project root, and parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua`), and no `cargo build` suggestion despite `ProjectKind::Rust` existing (`src/project.rs:78` — **not** `Cargo`, see §24). **PR #203 (open) closes all three**: `C-c c` runs `compile.run`, and the first prompt is prefilled from the detected project kind (`pmacs.compile.defaults`, seeded `rust = "cargo build"`). 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. Named limitation it does not fix: 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. **This row flips to Works when #203 merges**; per §25 a grade changes only on landed evidence | | 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first | | 11 | See background work | **Works but undiscoverable** | `*workers*` view via `M-x editor.list-workers`; `C-c C-k` cancel-at-point. No keybinding, no statusline spinner/progress indicator anywhere (§9) | | 12 | Close + restore | **Partial** | Per-file cursor+scroll (saveplace), recent files, minibuffer history, autosave recovery all restore zero-config. Open-buffer set and window layout do **not**: desktop-save is opt-in (`pmacs.session.desktop_mode(true)`) *and* a documented no-op under a daemon (`src/desktop.rs:323-326`, `:353-356`, Q#DS9) | @@ -388,10 +388,10 @@ A journey observation worth keeping verbatim from the audit: C-M-s` opens all folds, while opening a file, opening a terminal, and running a build have no bindings at all. -All three of that observation's examples have now been answered — -opening a file by `C-x C-f` (#162), opening a terminal by `C-c t` -(#173), and running a build by `C-c c` (Journey Stage 1b-1). **The quote -stays as written**: it names a standing bias in how new work gets bound, +Two of that observation's three examples have been answered — opening a +file by `C-x C-f` (#162) and opening a terminal by `C-c t` (#173). +**Running a build still has no binding on `main`**; PR #203 is open and +adds `C-c c`. **The quote stays as written either way**: it names a standing bias in how new work gets bound, not three isolated omissions, and three fixes do not retire a bias. What has changed is that the bias no longer has an uncontested example in the golden journey — the next new surface that ships without a binding is @@ -1544,17 +1544,18 @@ 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 (§1.2); a -compile keybinding + `cargo build`/`test` default (**done**: Journey -Stage 1b-1, from the existing `ProjectKind::Rust` — **not** `Cargo`, see -§24); a terminal keybinding (**done**: `C-c t`, #173); a welcome -buffer. The journey acceptance suite (§19) is the ratchet that +compile keybinding + `cargo build`/`test` default (**in flight**: +Journey Stage 1b-1, PR #203, from the existing `ProjectKind::Rust` — +**not** `Cargo`, see §24); a terminal keybinding (**done**: `C-c t`, +#173); a welcome buffer. 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. Journey Stage 1b is the named remainder, and it splits: **1b-1 — the -compile binding + project-kind defaults — is done**; 1b-2 (LSP spawn -guidance, step 6) and 1b-3 (the welcome buffer, step 4) remain. The -ratchet is now seeded with steps 2, 3, 5 and 9. +compile binding + project-kind defaults — is in flight as PR #203**; +1b-2 (LSP spawn guidance, step 6) and 1b-3 (the welcome buffer, step 4) +remain. The ratchet is seeded with steps 2, 3 and 5, and gains step 9 +when #203 lands. ### Priority 2: Make workspace and location explicit @@ -1625,10 +1626,10 @@ implementation — this list is direction, not commitment): `resolve_target_buffer` unification, the destination-scope substrate, and the first journey acceptance suite. It routes `pmacs .` into #165's dired buffer rather than growing a second directory surface. - **Stage 1b-1 — landed**: the compile binding and project-kind - defaults, with the prompt capturing its directory rather than - re-resolving it at accept time. **Stage 1b-2 / 1b-3 — remaining**: - LSP-failure surfacing, welcome buffer. + **Stage 1b-1 — in flight (PR #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 / 1b-3 — + remaining**: LSP-failure surfacing, welcome buffer. 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 960294e..a4c577b 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -260,6 +260,24 @@ If it does not, stop and repair the remote/fetch configuration. `docs/journey-stage1b1-compile-defaults-framing.md` revision 2, approved after one review round (two blocking, two major, all accepted). **Implemented; PR open.** +- **ON MERGE OF #203, flip four places to landed.** The PR deliberately + ships them as *in flight*, because `COHERENCE.md` §25 says a grade + changes only on landed evidence and the PR is open: `COHERENCE.md` + §2's step-9 verdict row (Partial → **Works**), §2's post-table + keybinding-inversion paragraph (third example answered), §20 Priority + 1 and its arc list (in flight → done), and + `docs/agent-handoff.md` §1's arc bullet (IMPLEMENTED → LANDED). + **Recorded here because an unowned doc flip is exactly how this + ledger's drift starts** — the same rule-4 precondition that kept #176's + lane alive past its merge. +- **A lexical path expectation is wrong for anything detection touched.** + `pmacs.project.detect` canonicalizes before walking + (`canonicalize_or_passthrough`, `src/project.rs:509-511`) while the + suite's `canon()` is lexical, so the compile-directory assertions + passed on Ubuntu and **failed both macOS legs**, where `/var` is a + symlink to `/private/var`. Fixed with a `detected_root()` expectation + and pinned by a **symlinked fixture**, which reproduces the disagreement + on any platform — a Linux-only bite could not have caught it. - **Bites found two vacuous pins of my own.** The nested-project pin passed with the keybinding removed, because `minibuffer.contents()` is `""` both for an empty prefill and for no minibuffer at all — `""` diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 34d353d..f5a7d70 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -104,8 +104,11 @@ commands, read `docs/active-work.md` immediately after this file. interaction islands added, config-registry adoption, background-work attribution. Its §2 grades the golden journey; **Journey Stage 1a moved that grade off "broken at step 3"** — see the arc bullet below. -- **Journey arc (P1) — Stage 1b-1 LANDED** (`docs/journey-stage1b1-compile-defaults-framing.md`). - Journey step 9 moves **Partial → Works**: `C-c c` runs `compile.run`, +- **Journey arc (P1) — Stage 1b-1 IMPLEMENTED, PR #203 OPEN** + (`docs/journey-stage1b1-compile-defaults-framing.md`). Not landed: per + `COHERENCE.md` §25 a grade changes only on landed evidence, so §2's + step-9 row still reads **Partial** and flips at merge. + Journey step 9 will move **Partial → Works**: `C-c c` runs `compile.run`, and the first prompt is prefilled from the detected project kind via `pmacs.compile.defaults` (seeded `rust = "cargo build"`, extensible from `init.lua`). Lua, tests and docs; no Rust change, no protocol diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs index c7b0569..74cd265 100644 --- a/tests/journey_acceptance.rs +++ b/tests/journey_acceptance.rs @@ -1354,6 +1354,23 @@ fn bound_detection_to(s: &EditorState, dir: &Path) { ); } +/// The project root as **detection** reports it. +/// +/// `pmacs.project.detect` canonicalizes before walking +/// (`canonicalize_or_passthrough`, `src/project.rs:509-511`), so a +/// `/var/folders/...` tempdir on macOS comes back as +/// `/private/var/folders/...`. `canon()` is *lexical* — it never +/// resolves symlinks — so it is the wrong expectation for any value +/// that has passed through detection, which is exactly what the compile +/// cwd is. Using it here failed both macOS CI legs while Ubuntu (where +/// `/tmp` is not a symlink) stayed green. +fn detected_root(path: &Path) -> String { + std::fs::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .into_owned() +} + fn minibuffer_active(s: &EditorState) -> bool { eval(s, "return pmacs.minibuffer.is_active()") } @@ -1483,7 +1500,9 @@ fn journey_step9_the_prompt_runs_in_the_directory_it_captured() { assert!(finished, "the accepted run must finish"); let text = named_text(&s, "*compilation*"); - let a_path = canon(a.path()); + // Detection-canonical, not lexical: the compile cwd came from + // `pmacs.project.detect`, and `pwd` reports the physical directory. + let a_path = detected_root(a.path()); assert!( text.contains(&format!("Directory: {a_path}")), "the header must name the directory the prompt captured, not the newly active one;\n{text}" @@ -1528,7 +1547,7 @@ fn journey_step9_the_offered_command_builds_the_project() { let text = named_text(&s, "*compilation*"); assert!( - text.contains(&format!("Directory: {}", canon(td.path()))), + text.contains(&format!("Directory: {}", detected_root(td.path()))), "the build runs in the detected project root;\n{text}" ); assert!( @@ -1669,3 +1688,57 @@ fn binary_available(name: &str) -> bool { .output() .is_ok_and(|o| o.status.success()) } + +/// **N** — the compile directory is *detection*-canonical, not lexical. +/// +/// This pin exists because its absence broke both macOS CI legs while +/// Ubuntu stayed green: `/var` is a symlink to `/private/var` there, so +/// a tempdir's lexical and canonical paths differ, and the original +/// assertions used the lexical one. +/// +/// Reproducing it on Linux needs an explicit symlink — which is also a +/// real configuration `Workspace::detect`'s own doc comment names +/// ("`/tmp/sandbox/foo` symlinked to `/home/user/code/foo`"). Launching +/// through the link makes the two paths disagree on every platform, so +/// the regression can no longer hide behind a filesystem that happens +/// not to use symlinks. +#[cfg(unix)] +#[test] +fn journey_step9_the_compile_directory_is_detection_canonical() { + let parent = tempfile::tempdir().expect("tempdir"); + let real = parent.path().join("real"); + std::fs::create_dir_all(real.join("src")).expect("mkdir real"); + std::fs::write( + real.join("Cargo.toml"), + b"[package]\nname = \"journey-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .expect("write Cargo.toml"); + std::fs::write(real.join("src/main.rs"), b"fn main() {}\n").expect("write main.rs"); + + let link = parent.path().join("link"); + std::os::unix::fs::symlink(&real, &link).expect("symlink"); + + // Precondition, or the pin is vacuous: the two spellings must + // actually differ, which is the whole condition macOS creates for + // free and Linux does not. + assert_ne!( + canon(&link), + detected_root(&link), + "the fixture must make lexical and canonical paths disagree" + ); + + let mut s = walk_to_open_file(&link, "Cargo.toml"); + press_compile_chord(&mut s); + exec(&s, "pmacs.minibuffer.set_contents('pwd')"); + press(&mut s, KeyCode::Enter); + let finished = pump_processes_until(&mut s, 10_000, |s| { + named_text(s, "*compilation*").contains("exited") + }); + assert!(finished, "the accepted run must finish"); + + let text = named_text(&s, "*compilation*"); + assert!( + text.contains(&format!("Directory: {}", detected_root(&link))), + "the header must name the directory detection resolved to;\n{text}" + ); +} From 732aa282cc82be7023abc003373d3cd1642586d6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 21:45:28 -0400 Subject: [PATCH 6/6] docs(journey): keep keybinding audit state aligned with PR #203 --- COHERENCE.md | 8 ++++---- docs/active-work.md | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 2154a87..ebe3fa6 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -392,10 +392,10 @@ Two of that observation's three examples have been answered — opening a file by `C-x C-f` (#162) and opening a terminal by `C-c t` (#173). **Running a build still has no binding on `main`**; PR #203 is open and adds `C-c c`. **The quote stays as written either way**: it names a standing bias in how new work gets bound, -not three isolated omissions, and three fixes do not retire a bias. What -has changed is that the bias no longer has an uncontested example in the -golden journey — the next new surface that ships without a binding is -evidence the pattern is live again, and should be read that way. +not three isolated omissions, and two fixes do not retire a bias. +**Running a build remains its uncontested golden-journey example until +#203 lands.** A new surface that ships without a binding would be further +evidence the pattern is live, and should be read that way. --- diff --git a/docs/active-work.md b/docs/active-work.md index a4c577b..10524a4 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -264,8 +264,9 @@ If it does not, stop and repair the remote/fetch configuration. ships them as *in flight*, because `COHERENCE.md` §25 says a grade changes only on landed evidence and the PR is open: `COHERENCE.md` §2's step-9 verdict row (Partial → **Works**), §2's post-table - keybinding-inversion paragraph (third example answered), §20 Priority - 1 and its arc list (in flight → done), and + keybinding-inversion paragraph (third example answered; the build is + no longer its uncontested example), §20 Priority 1 and its arc list (in + flight → done), and `docs/agent-handoff.md` §1's arc bullet (IMPLEMENTED → LANDED). **Recorded here because an unowned doc flip is exactly how this ledger's drift starts** — the same rule-4 precondition that kept #176's