diff --git a/COHERENCE.md b/COHERENCE.md
index dd4a16e..ebe3fa6 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 | **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,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.
+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 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.
---
@@ -1542,14 +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 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 (**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: 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 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
@@ -1620,8 +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 — remaining**: compile defaults, LSP-failure surfacing,
- bindings, 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
@@ -1713,6 +1721,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 fc8c695..10524a4 100644
--- a/docs/active-work.md
+++ b/docs/active-work.md
@@ -253,6 +253,100 @@ 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 IMPLEMENTED, PR OPEN
+
+- **Branch `journey-stage1b1-compile-defaults`**, worktree
+ `../pmacs-journey-1b1`, based on `githubsucks/main` @ `22df6ab`.
+ `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; 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
+ 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 — `""`
+ 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
+ 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
+ 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/agent-handoff.md b/docs/agent-handoff.md
index d7e4d2b..f5a7d70 100644
--- a/docs/agent-handoff.md
+++ b/docs/agent-handoff.md
@@ -104,6 +104,51 @@ 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 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
+ 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/journey-stage1b1-compile-defaults-framing.md b/docs/journey-stage1b1-compile-defaults-framing.md
new file mode 100644
index 0000000..3a075d1
--- /dev/null
+++ b/docs/journey-stage1b1-compile-defaults-framing.md
@@ -0,0 +1,691 @@
+# Journey Stage 1b-1 — make building discoverable
+
+**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).
+
+## 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()`.
+
+**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`
+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 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
+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 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 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:* 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.
+
+**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
+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 detected Cargo project**.
+
+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
+
+`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
+```
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..74cd265 100644
--- a/tests/journey_acceptance.rs
+++ b/tests/journey_acceptance.rs
@@ -1297,3 +1297,448 @@ 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()
+ ),
+ );
+}
+
+/// 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()")
+}
+
+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*");
+ // 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}"
+ );
+ 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") {
+ 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;
+ }
+ 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: {}", detected_root(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())
+}
+
+/// **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}"
+ );
+}