diff --git a/ADRs/0023-chat-proposes-edits-the-human-lands-them.md b/ADRs/0023-chat-proposes-edits-the-human-lands-them.md new file mode 100644 index 0000000..a1f21ab --- /dev/null +++ b/ADRs/0023-chat-proposes-edits-the-human-lands-them.md @@ -0,0 +1,63 @@ +# ADR-0023 — Chat proposes edits; the human lands them, and every landed edit is reversible + +- **Status:** Accepted · 2026-11-08 +- **Refs:** invariants W1–W5, S4, M1, E3, E5 · ADR-0004, ADR-0005, ADR-0012, ADR-0016, ADR-0022 · + GH #253 · [docs/proposals/2026-11-08-chat-edits-notes.md](../docs/proposals/2026-11-08-chat-edits-notes.md) + +## Context + +Chat was built as a reader: it answered from the notes and never touched them, and W2 said so +("B2 never authors the note body"). Daily use asked for more. "Clean up this doc" (GH #253) is a +request for an editing partner, and a partner who can only describe an edit in prose is one I then +have to retype. The pivot is for chat to work the notes with me. + +The reason W2 existed still stands: the vault is mine, and B2 must never change it behind my back. +So the question was how a model can author bytes without B2 making an unbidden write. The answer +is to separate *proposing* from *landing*. + +Git was considered as the undo store and rejected: the vault happens to be a git repo, but git is +per commit, not per edit; one commit per accepted edit would have B2 writing into `.git`, sweeping +in my own unsaved typing, and handling dirty trees and rebases. B2 keeps no dependency on git. + +## Decision + +- **The model proposes; it never writes.** The one edit-shaped tool, `b2_edit`, is + `{note, find, replace}`: replace one exact, unique occurrence of `find` in the note body. It + returns a diff or an `error:` result (no match, several matches, cap hit) and touches no file. + Within a turn, proposals compose on a per-conversation working copy the model's reads see. +- **Landing is my command.** An accepted proposal is written by `Vault::apply_edits` through the + existing guarded save (`save_guarded`): revision-checked, body-only, frontmatter untouched, + re-projected model-free. An outside change since the proposal fails as `WriteConflict`. W1 holds + literally: until I accept, nothing has changed. +- **Review-first by default, opt-out per conversation.** Every proposal waits for Apply unless I + switch the conversation to *apply as you go*. That switch is one gesture, session-scoped, and + never a persisted default. +- **Every landed edit is recorded and reversible, one edit at a time.** The record holds the hunk, + the before and after bytes and both revisions. Undo restores the before bytes when the file is + unchanged, else reverse-applies the hunk if its text is still unique, else refuses and shows the + prior text. Never a guess, never a merge. +- **The journal is disposable cache under `.b2/edits/`.** It is B2-held copies of my own bytes; + deleting it loses undo history and nothing else. S4 is clarified: the vault is still the only + truth, and the journal is a cache like the index. +- **Hunks, not whole-note rewrites.** A whole-note rewrite from a small model drops paragraphs + silently; a hunk can only change what it names, yields a precise diff, and reverses exactly. +- **Every edit is visible.** Each proposal renders as a diff card with a status (pending, applied, + undone, conflict); landed edits appear in a Changes list with Undo; the answer can never claim an + edit the ledger does not show (ADR-0022's `tools` principle, extended). Diff text is model + output and renders through the escaping path (E5). +- **No delete, move or rename from chat.** W4 is unchanged. Frontmatter and resources are out of + the tool's reach. +- **The loop and the writes live in `b2-core`** behind façade methods; the CLI and desktop are + dumb adapters over the same proposals, apply and undo (E3, ADR-0012). + +## Consequences + +- W2 and W3 are amended (see `invariants.md`): B2 may carry model-drafted body bytes, but lands + only bytes I accepted, and the on-command write list gains *apply an accepted edit* and *undo an + applied edit*. +- How well a given model drives `b2_edit` is a quality question for the harness (ADR-0013): clean + match rate, preservation of links and headings, judged quality, honesty when nothing needs + changing. The eval decides whether edit mode is on by default for the shipped model. +- Phase 1 keeps the journal in memory; durability under `.b2/edits/` and the opt-out toggle follow + once undo is proven on the in-memory form. +- `FakeLlm` scripts an edit call so the propose, apply and undo paths are covered model-free (E2). diff --git a/ADRs/README.md b/ADRs/README.md index b871784..d0d22ad 100644 --- a/ADRs/README.md +++ b/ADRs/README.md @@ -34,3 +34,4 @@ records say *why* an entry reads the way it does. | [0020](0020-embeddings-inside-the-single-binary.md) | Embeddings inside the single binary: candle + hf-hub, provisioned by `b2 init` | | [0021](0021-concurrency-is-serialized-on-sqlites-own-locks.md) | Many readers, one writer, serialized on SQLite's own locks | | [0022](0022-the-chat-seam-carries-tool-calls-for-bounded-turns.md) | The chat seam carries tool calls; a tool-using turn is bounded and degrades | +| [0023](0023-chat-proposes-edits-the-human-lands-them.md) | Chat proposes edits; the human lands them, and every landed edit is reversible | diff --git a/docs/architecture.md b/docs/architecture.md index 15ba484..46cee9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,6 +112,10 @@ missing final newline all survive (W5). Against that backdrop, every byte B2 wri mechanics of a command you invoked (W1, ADR-0004). The complete list of on-command writes is [invariants.md W3](invariants.md). +Chat is the one place a model drafts body bytes, and it still cannot write them: a `b2_edit` +tool call is a *proposal*, shown as a diff, landed only by your Apply through the same guarded +save, and recorded so one Undo reverses exactly that edit (ADR-0023). + `b2 mv` never breaks the graph. A note's identity is its path, so a move is a re-key, not a rebuild: children reference `notes(path)` with `ON UPDATE CASCADE`, so one statement carries chunks, vectors, and edges across, and inbound link text is repaired in exactly the files the diff --git a/docs/invariants.md b/docs/invariants.md index 470f5ab..6df2e49 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -48,7 +48,9 @@ drops in without a redesign. ([index-engine.md](index-engine.md) §8, [GH #170](https://github.com/AlteredCraft/B2/issues/170)) - **S4. No durable B2-derived state outside the Markdown.** No event log, no sidecar files, no - facts that live only in the index. The scope is *B2-derived* data. Your own folder tree is + facts that live only in the index. The scope is *B2-derived* data. The chat edit journal + under `.b2/edits/` (ADR-0023) is disposable cache holding copies of your own bytes: delete + it and you lose undo history, nothing else. Your own folder tree is vault material, and the filesystem is authoritative for it: folders are never projected, and the tree listing is a live walk of the disk. ([data-model.md](data-model.md), "Folders") - **S5. A schema change is a version bump plus a rebuild, never a data migration.** @@ -61,11 +63,13 @@ drops in without a redesign. mechanics of a command you ran (W3). Reading a vault (walking it, projecting it, reindexing it) writes nothing. `reindex` runs unchanged on a read-only vault, and a git-versioned vault shows no diff from having been indexed. ([data-model.md](data-model.md) §1) -- **W2. B2 never authors the note body, and never asks it to carry B2 syntax.** The body is - 100% your document. The one body edit B2 composes itself is the mechanical move repair: - when a note moves, B2 rewrites the *path text* inside inbound `[[oldpath|alias]]` links so - they keep resolving. It fixes a link you already wrote. It never adds one, and aliases - survive verbatim. (The editor save, `Vault::write`, also replaces the body, but with bytes +- **W2. B2 lands no body bytes you did not accept, and never asks the body to carry B2 + syntax.** The body is your document. Two kinds of body bytes come from B2 rather than from + your keyboard, and both land only on your command: the mechanical move repair (when a note + moves, B2 rewrites the *path text* inside inbound `[[oldpath|alias]]` links so they keep + resolving; it never adds a link, and aliases survive verbatim), and a chat edit you + accepted (ADR-0023). A chat edit is a proposal until you accept it; the model's tool call + writes nothing. (The editor save, `Vault::write`, also replaces the body, but with bytes *you* wrote; B2 is only the carrier, W3.) ([data-model.md](data-model.md) §0) - **W3. The on-command writes are a short, closed list:** - `b2 link` appends one `b2_relations:` entry (frontmatter, never the body). @@ -76,6 +80,10 @@ drops in without a redesign. frontmatter bytes, body untouched. B2 owns no line in that block and judges none. - Import (`Vault::import_file` / `import_path`): the handed bytes copied verbatim, then projected. + - Apply an accepted chat edit (`Vault::apply_edits`): the same guarded splice, body only, + of hunks you reviewed and accepted (ADR-0023). Refused on a changed revision. + - Undo an applied chat edit (`Vault::undo_edit`): the guarded restore of the recorded prior + bytes, or the exact reverse hunk; refused, never guessed, when neither applies. - Create, move, and delete of notes, resources, and folders, on explicit command. - **W4. B2 never deletes, moves, or archives your files on its own.** Consequences of your edits (orphans, dangling links, hash-matched move candidates) are surfaced, flagged, or diff --git a/docs/proposals/2026-11-08-chat-edits-notes.md b/docs/proposals/2026-11-08-chat-edits-notes.md new file mode 100644 index 0000000..1120b99 --- /dev/null +++ b/docs/proposals/2026-11-08-chat-edits-notes.md @@ -0,0 +1,194 @@ +# Proposal: chat becomes an editor of the notes + +- **Status:** Proposal, 2026-11-08. Decisions taken 2026-11-08 (see the end); nothing built yet. +- **Refs:** invariants W1 to W5, S4, M1, E3, E5 · ADR-0004, ADR-0005, ADR-0012, ADR-0016, + ADR-0022 · GH #253 (chat doesn't know which note is open) + +## The pivot in one paragraph + +Today chat is a reader. It answers from the notes and never touches them (W2: "B2 never +authors the note body"). The pivot: in chat, the model may **propose edits** to notes, and +work with me across several turns to clean up, restructure or rewrite them. Three things are +non-negotiable: I can **see** every edit before or after it lands, as a diff against the +note; every landed edit is **undoable**, one at a time, exactly; and nothing lands that I did +not **accept**. The human stays the judge. This keeps the spirit of W1 (no unbidden writes) +while changing the letter of W2 and W3, so it needs an ADR, not just code. + +## What changes in the rules + +| Rule today | Rule after | Why | +|---|---|---| +| W2: B2 never authors the body. | B2 never **lands** body bytes I did not accept. The model may draft them; the draft is shown as a diff; only my accept writes it. | The model is an author now, but the write is still the mechanics of my command (accept). | +| W3: closed list of on-command writes. | Add two entries: *apply an accepted edit* (a guarded save of the accepted bytes) and *undo an applied edit* (a guarded save of the recorded prior bytes). | Both go through the existing `save_guarded` path, so the revision guard and re-projection are unchanged. | +| S4: no durable B2-derived state outside Markdown. | Clarify: an **edit journal** under `.b2/` (before and after bytes of each landed edit) is disposable cache, like the index. Deleting it loses undo history and nothing else. | Undo that survives a restart needs the prior bytes stored somewhere. The vault stays the truth. | +| W4: never delete, move or archive on its own. | Unchanged. The model gets no delete or move tool in this proposal. | Reversibility of a delete is a different problem; defer. | + +Everything else holds: identity is the path (L1), the index is a projection (S2), the model +is behind the seam (M1), model output is untrusted (E5), no engine logic in adapters (E3). + +## How it works + +### The model proposes; B2 holds; I accept + +The agent turn already exists (ADR-0022, `why_similar`). This proposal gives `ask` the same +loop with a wider tool set, and adds the first tool that is not a read: + +| Tool | Kind | What it does | +|---|---|---| +| `b2_read` | read | A note's passages (exists). | +| `b2_search` | read | Hybrid search, so the model can find the notes to work on. | +| `b2_neighbors` | read | A note's links (exists). | +| `b2_edit` | **propose** | `{note, find, replace}`: replace one exact, unique occurrence of `find` in the note body with `replace`. Returns the resulting diff, or an error result ("no match" / "matched 3 times, be more specific"). | + +`b2_edit` does **not** write to disk. The core keeps a per-conversation **working copy** of +each touched note (disk bytes plus the accepted and pending edits, in order). The model's +reads see the working copy, so a five-edit cleanup composes correctly within one turn. Each +successful `b2_edit` call becomes a **proposal**: note path, the hunk (find, replace, line +numbers), the base revision, a status (`pending`). + +The turn's answer returns the proposals beside the text and citations (`AnswerView.edits`). +Nothing has touched the vault yet (W1 holds exactly, at this point). + +**Accept** is a façade call: `Vault::apply_edits(note, edits, base_revision)`. It applies +the hunks to the current disk bytes and saves through `save_guarded`, so an outside change +to the file since the proposal (a typed edit, a `git pull`) fails with the existing +`WriteConflict`, shown as "this note changed since the edit was proposed; ask again". +Frontmatter is never in the hunk's reach: `b2_edit` works on the body only, the way +`Vault::write` does. On success the edit is recorded in the journal with before and after +bytes and marked `applied`. + +### Why find/replace hunks and not whole-note rewrites + +- A whole-note rewrite from a small local model silently drops paragraphs. A hunk can only + change what it names. +- A hunk makes a precise diff for free, and precise undo is the reverse hunk. +- A hunk on a long note costs a few hundred tokens, not the whole note twice. +- The uniqueness rule turns a vague edit into an error the model can fix, not a wrong edit. + +This is the shape every coding agent converged on, for the same reasons. Whole-note +replacement can be a later tool for "rewrite this short note", guarded by a size cap. + +### Two modes, one default + +- **Review first (default).** Every proposal waits for me. A turn's card shows the diffs + with *Apply*, *Apply all*, *Skip*. Nothing lands until I click or press the key. +- **Apply as you go (per conversation, opt-in).** Edits land as the model makes them, each + shown as a diff the moment it lands, each undoable. For "clean up these twelve meeting + notes the same way" after I've seen the first two. + +Review-first is the default. Opting out must be one gesture: a toggle in the chat pane, +and *Apply all* on a card also offers "and keep applying this conversation". The toggle is +session-scoped and never becomes a persisted default. Phase 1 ships review-first; the +toggle lands in Phase 2 once undo is durable. A cap on edits per turn (configurable, loud, +in Settings) bounds both modes. + +### Transparency: where I see it + +1. **In the answer.** Each proposal is a card in the transcript: note title and path, a + unified diff (removed in red, added in green), status chip (*pending*, *applied*, + *undone*, *conflict*). The card is an in-app button that opens the note at the hunk. + Diff text is model output: rendered through `escapeHtml`, never `innerHTML` (E5). +2. **In the editor.** After an edit lands in the open note, the changed lines carry a + gutter mark and a soft highlight until I next type in them. CodeMirror decorations; no + bytes injected into the note (W2 for B2 syntax still holds). +3. **In a Changes list.** A section at the top of the chat pane: every edit landed this + conversation, newest first, grouped by note, each with *Undo*. The journal view. +4. **Honest answers.** If the model asked for an edit the core refused (no match, cap hit), + the card says so. If the model wrote "I've cleaned it up" and made no `b2_edit` call, the + card list is empty and says "no edits were proposed", so the text can't overstate what + happened (ADR-0022's `tools` principle, extended). + +### Undo: precise, per edit + +Each applied edit records: path, the hunk, the full before bytes, the full after bytes, +revision before and after, the turn it came from. Undo of one edit: + +1. If the file still hashes to the edit's after-revision: write the before bytes + (`save_guarded`). Exact. +2. Else, if the edit's `replace` text still occurs exactly once: apply the reverse hunk + (`replace` → `find`). Exact for that hunk, even after I typed elsewhere. +3. Else: refuse with "the edited text has changed since; open the note to fix by hand", + and offer the before bytes in a read-only view to copy from. Never a guess. + +Also *Undo all from this answer* and *Undo all in this note this session*, which are the +per-edit undo run newest-first with the same three rules. Redo is re-applying the recorded +hunk under the same rules. + +Phase 1 keeps the journal in memory (lost on quit). Phase 2 writes it under `.b2/edits/` +so undo survives a restart, with a size cap and age limit, both in Settings. B2 does not +use git for this, even when the vault is a git repo: git is per commit, not per edit, and +one commit per accepted edit would mean B2 writing into `.git`, sweeping in your own +unsaved typing, and handling dirty trees and rebases. A B2-owned folder of before and after +bytes is smaller and has no dependency. Git stays your backstop, untouched by B2. + +### CLI parity (E3) + +The desktop and the CLI are the two dumb adapters, so the façade grows and both call it: + +- `b2 ask --edit "clean up this note" --note PATH` runs the agent turn and prints the + proposals as diffs; in a TTY it asks `apply? [y/n/a]`; with `--json` it emits them and + exits, so an agent can read them. +- `b2 edits apply PROPOSAL`, `b2 edits list`, `b2 edits undo ID`, all with `--json`. + +### Focus note (#253) is the first step + +"Clean up this doc" needs chat to know what "this doc" is. #253's shape (an optional focus +path on `ask`, its passages numbered first, one sentence in the prompt, a chip in the pane) +is unchanged and becomes step one of phase 1. The agent turn makes the focus note the +default `note` argument of every tool, the way the why-turn defaults to its pair. + +## Where the code goes + +| Piece | Crate | Note | +|---|---|---| +| `b2_edit` tool spec, prompt, hunk apply/reverse, working copy, proposal and journal types | `b2-core` (`chat.rs`, new `edit.rs`) | Pure, deterministic, tested against `FakeLlm`. `FakeLlm` gains a scripted edit call so the loop, accept and undo are covered model-free (E2). | +| `Vault::ask_agent` (or `ask` with an `edit: bool`), `Vault::apply_edits`, `Vault::undo_edit`, `Vault::edit_journal` | `b2-core` (`vault/chat.rs`, `vault/edits.rs`) | Writes go through `save_guarded`. | +| View types: `EditProposal`, `EditRecord`, `AnswerView.edits` | `b2-core` views, reused as IPC and `--json` | One contract, two adapters. | +| IPC commands: `ask` gains `edit`/`focus`, plus `apply_edits`, `undo_edit`, `list_edits` | `b2-desktop` | Parse, call, return. | +| Diff rendering, proposal cards, Changes list, editor highlights, keys for Apply/Undo | `ui/` (`chat.ts`, `chatview.ts`, new `edits.ts`) | Pure logic in tested modules; K1 keyboard-complete. | +| `--edit`, `b2 edits` | `b2-cli` | | +| Eval cases | `docs/evals` harness | See below. | + +## Measure before trusting it (ADR-0013) + +Whether gemma4:e2b can drive `b2_edit` well is a quality question, so it is answered by the +harness, not by intuition. New cases on the eval corpus: + +- **Clean match rate.** Of the edits a model proposes on a set of "clean up" prompts, how + many apply without a uniqueness or no-match error. +- **Preservation.** After the edits, every `[[wikilink]]`, heading and frontmatter key the + note had is still present unless the prompt asked to remove it. +- **Judged quality.** A model judge scores the cleaned note against the prompt, as the + harness scores answers today. +- **Honesty.** A prompt the model cannot satisfy (nothing to clean) yields no proposals and + an answer that says so. + +If the default model scores poorly, review-first still makes the feature safe, but the eval +decides whether edit mode is on by default or behind the model tiers from PR #250. + +## Phases + +1. **Decide.** ADR amending W2/W3 and clarifying S4. Update `invariants.md`, + `architecture.md` ("the write discipline"), `index-engine.md` §6. +2. **Phase 1: propose and accept.** #253 focus note; agent `ask` with `b2_read`, + `b2_search`, `b2_neighbors`, `b2_edit`; proposal cards with diffs; Apply/Apply all/Skip; + in-memory journal; per-edit Undo in the Changes list; CLI `--edit` and `--json`; `FakeLlm` + coverage; first eval cases. +3. **Phase 2: durable and visible.** Journal under `.b2/edits/`; undo across restarts; + editor gutter marks for landed edits; Undo all from this answer; the *apply as you go* + toggle; Settings for caps. +4. **Phase 3: wider authoring.** `b2_create_note`; whole-note + rewrite tool for short notes; multi-note batch prompts; eval results drive the default. + +## Decisions taken (2026-11-08) + +1. **Default mode:** review-first, with a one-gesture opt-out to *apply as you go* for the + rest of the conversation. +2. **The journal** lives under `.b2/edits/` as disposable cache. S4 is clarified, not + changed. Git is not used. +3. **Edit grain:** find/replace hunks with the uniqueness rule. + +## Out of scope here + +Deleting, moving or renaming notes from chat (W4 unchanged). Editing frontmatter from chat +(typed links already have `link`). Editing resources. A persisted chat transcript (S4). diff --git a/docs/proposals/2026-11-08-chat-edits-plan.md b/docs/proposals/2026-11-08-chat-edits-plan.md new file mode 100644 index 0000000..9193a03 --- /dev/null +++ b/docs/proposals/2026-11-08-chat-edits-plan.md @@ -0,0 +1,170 @@ +# Implementation plan: chat edits notes, Phase 1 (GH #255) + +The build order for ADR-0023's Phase 1, one PR per child issue, each shippable alone. +Written against the code as of `f9b34d5`. Design: [the proposal](2026-11-08-chat-edits-notes.md). + +## The one structural decision the code forces + +`Vault` is opened fresh for every desktop command (`open_semantic(state)` in +`commands.rs`) and the core has no clock (E1). So the per-conversation state (the working +copy, pending proposals, the undo journal) cannot live inside `Vault`. It is a value type the +core defines and the **adapter owns**: `b2_core::edit::EditSession`. The desktop holds one in +`AppState` behind a mutex and hands `&mut` to each façade call; the CLI holds one as a local +in `cmd_chat`. All logic stays in the core (E3); the adapter only stores the value and passes +in the time. "New conversation" drops the session, and with it the pending proposals (not the +landed edits: those are on disk, and in Phase 1 their undo records go with the session, which +the pane says plainly). + +## PR 1: focus note (#253) + +The smallest change, and every later PR needs it. + +- `vault/chat.rs`: `ask` gains `focus: Option<&str>`. When set, resolve it, take its passages + (`db::note_chunk_ids` + `chunk_detail`, as `tool_read` does) up to `FOCUS_PASSAGES` (new + constant, default 8, in `chat.rs`'s constants block), number them first, fill the rest from + `search_chunks` minus duplicates. `chat::build_request` gains the focus path and appends one + sentence to the system prompt: "The user is viewing ``; 'this note' or 'this doc' means + it." `condense_query` gets the same sentence. +- If the cap trimmed the note, the system message says so in one line (limits are loud). +- `commands.rs` `ask` takes `focus: Option`; `api.ts`, `main.ts` `sendChat` passes + `state.current?.path` when the open document is a note. The pane shows an "about: title" + chip beside the composer, so what the model was told is visible (`chatview.ts`). +- CLI: `b2 ask --note PATH`, `b2 chat --note PATH` (`args.rs`, `chat.rs`). +- Tests: `tests/ask.rs` pins that no focus is byte-identical to today's request (compare + `ChatRequest` through a recording `LlmProvider`); with focus, passage 1 is the focus note and + the prompt names the path; a focus of more than `FOCUS_PASSAGES` passages adds the trim line. + `quickstart.md`: the flag. Eval case lands with PR 6. + +## PR 2: `edit.rs`, the pure half (#258) + +No façade, no IO. Everything here runs against strings. + +```rust +pub struct Hunk { pub find: String, pub replace: String } +pub enum HunkError { NoMatch, Ambiguous(usize), Empty } +pub fn apply_hunk(body: &str, h: &Hunk) -> Result<(String, Span), HunkError> +pub fn reverse(h: &Hunk) -> Hunk +pub fn unified_diff(before: &str, after: &str, path: &str) -> String // `similar` crate +pub struct EditProposal { id, path, hunk, line: usize, diff: String, base_revision: String, status: EditStatus } +pub enum EditStatus { Pending, Applied, Undone, Conflict } +pub struct EditRecord { proposal: EditProposal, before: String, after: String, revision_before, revision_after, applied_at: String } +pub struct EditSession { working: BTreeMap, proposals: Vec, journal: Vec, max_edits_per_turn: usize } +``` + +- `EditSession::propose(path, disk_body, disk_revision, hunk) -> Result<&EditProposal, HunkError>` + applies the hunk to the working copy (disk body on first touch), records the proposal, and + returns it. `pending_for(path)`, `working_body(path)`. Cap: the N+1th `propose` in a turn + returns `HunkError::Cap(n)`; `begin_turn()` resets the count. +- `Empty` refuses `find == ""` (it would match everywhere) and `find == replace`. +- Body only by construction: callers pass `ParsedNote::body()`, never the raw file. +- `chat.rs`: `TOOL_EDIT = "b2_edit"`, its `ToolSpec` (`note` defaults to the focus note; + `find` and `replace` required), `TOOL_SEARCH = "b2_search"` spec, `EDIT_AGENT_SYSTEM_PROMPT`. +- `llm.rs` `FakeLlm`: on an agent request that offers `b2_edit`, has at least one passage and + no `b2_edit` exchange yet, emit one call with `find` = the first line of passage 1 and + `replace` = that line plus ` (edited)`. Deterministic, so the suite covers the loop (E2). +- `Cargo.toml` (`b2-core`): add `similar` for the diff text. No other dependency. +- Tests, `tests/edit.rs`: apply, reverse round trip (`proptest` over bodies and hunks, as + `props.rs` does), ambiguity count, no match, empty, frontmatter never in reach, cap fires on + the N+1th call and not the Nth (the gate-falsification habit), diff text names the path. + +## PR 3: the façade (#256) + +`vault/edits.rs`, new, plus additions to `vault/chat.rs`. + +- `Vault::ask_edit(llm, question, history, focus: Option<&str>, session: &mut EditSession, on_token) -> AnswerView`: + the ADR-0022 loop generalised. Extract the loop from `why_similar` into a private + `run_agent_turn(req, tools, desk, run_tool: impl FnMut(..))` so the two turns share it, then + `why_similar` is the first caller and `ask_edit` the second. Tools: `b2_read` (reads the + **working copy** when the session has one for that note), `b2_search` (`search_chunks` at + `ASK_PASSAGES`, numbered on the ledger), `b2_neighbors`, `b2_edit`. Round 1 is not hidden + here: this turn's text is the model's running commentary, so stream from round 1 and let + the last round offer no tools, as today. +- Degrade: a provider that refuses tools gets `ask` with focus and `edits: []`. +- `AnswerView` gains `edits: Vec` (serde-skipped when empty, so `why`'s JSON is + unchanged). +- `Vault::apply_edits(session, path, ids: &[String], base_revision, now: &str) -> ApplyReport`: + resolve, read disk, check revision (else `WriteConflict`, proposals → `Conflict`), apply the + named pending hunks in proposal order to the **disk** body (a hunk that no longer matches → + `Conflict`, nothing written), then `save_guarded` with the new body. Record one `EditRecord` + per hunk with before/after bytes and both revisions, status `Applied`. Drop the working + copy for that path (disk is the truth again). +- `Vault::undo_edit(session, id, now) -> UndoReport`: the three rules. Rule 3 is + `Error::UndoRefused { path }` and the report carries `prior_text` for the UI to show. + Status → `Undone`; a redo is `propose` + `apply` of the recorded hunk, nothing new. +- `Vault::edit_journal(session) -> Vec` is a borrow, not a façade op; the adapters + serialise the session's view directly. +- Errors: `Error::UndoRefused`, `Error::EditLimit { limit }` (never degraded, like + `ToolCallLimit`). Both mapped in `b2-cli/error.rs` and `b2-desktop/error.rs` with generic, + actionable text. +- Tests, `tests/ask_edit.rs` on a golden-vault copy with `FakeLlm`: the turn yields one pending + proposal and the vault bytes are unchanged (hash the tree before and after); `apply_edits` + lands exactly the hunk and nothing else (diff the file); apply against a changed file is + `WriteConflict` and writes nothing; undo rules 1, 2, 3 each with a test named for the rule; + a provider that refuses tools degrades to `ask` with `edits: []`; the edit cap fires. +- Docs: `index-engine.md` §6 gets the `ask_edit` tool table beside `why_similar`'s. + +## PR 4: desktop (#259) + +- `AppState`: `edits: Mutex`; `new_conversation` IPC (or the existing "new") + replaces it. Cap read from Settings as `max_tool_calls` is (`chat.rs` prefs, `apply_tool_cap` + pattern), env `B2_LLM_MAX_EDITS_PER_TURN`. +- Commands: `ask` gains `focus`, `edit: bool`; new `apply_edits(path, ids, base_revision)`, + `undo_edit(id)`, `list_edits()`. Each is parse, lock, one façade call, return. The clock is + the host's (`chrono`-free: `SystemTime` to RFC 3339 via `time`, already in the tree? check + `Cargo.lock`; else pass seconds). +- `ui/src/types.ts`: `EditProposal`, `EditRecord`, `AnswerView.edits`. +- `ui/src/edits.ts` (pure, tested): proposal rows (`chat:edit::` keys, so they join + `chatRows` and inherit the keyboard walk), buttons by status, grouping of the journal by + note, and `applyAllIds(turn)`. +- `chatview.ts`: a card per proposal under its turn: title, path, diff in a `
` with
+  per-line `del`/`ins` classes computed from the diff text (every line through `escapeHtml`),
+  status chip, *Apply* / *Skip* buttons, *Apply all* on the turn. A Changes section above the
+  log when the journal is non-empty: grouped by note, newest first, *Undo* per row, a line
+  "Undo history lasts for this conversation" (Phase 1 honesty).
+- `main.ts`: apply → `api.applyEdits(path, ids, revision)` where `revision` is
+  `state.current.revision` if the note is open, else a fresh `api.read`'s; on success chain
+  the revision as a save does and re-read the note (the `afterSave` path at ~1159); on
+  `WriteConflict` mark the cards *conflict* and flash the existing message. Undo mirrors it. A
+  dirty editor buffer on the same note: flush first (as the link path at ~2196 does).
+- Chat pane: an "Edit mode" toggle next to "new" (off → plain `ask`), the "about:" chip
+  from PR 1, and the intro text loses "Nothing here is written to your notes" in favour of
+  "Edits are proposed as diffs and land only when you apply them."
+- `bindings.ts`: chords for Apply, Skip, Apply all, Undo on the focused row; the sheet test
+  enforces documentation. Focus restored by row key across the repaint.
+- Settings → Chat: "Edits per reply" field, the `toolCapInput` three-state pattern.
+- Verify: `ui/` suite; the real `ui/` in Chrome against a mocked `__TAURI_INTERNALS__` fed
+  `--json` proposals; then `make app` with `B2_LLM=fake` end to end.
+
+## PR 5: CLI (#260)
+
+- `args.rs`: `--edit` on `Ask` and `Chat`; `Edits { List | Apply { ids } | Undo { id } }`.
+- `chat.rs`: `cmd_ask` with `--edit` prints the answer, then each proposal's diff; in a TTY,
+  `apply? [y/n/a]` per proposal, reading stdin; `--json` emits the `AnswerView` and exits.
+  `cmd_chat` keeps one `EditSession` for the REPL and adds `/apply`, `/apply all`, `/undo ID`,
+  `/edits`. `b2 edits …` outside a REPL reports "no edits in this session" until Phase 2.
+- Writes require an explicit vault, as `write.rs` commands do (`require_vault`).
+- Tests, `crates/b2-cli/tests/cli.rs` with `B2_LLM=fake`: `ask --edit --json` emits one pending
+  proposal and leaves the vault tree's hash unchanged; piping `y` applies exactly the hunk.
+- `quickstart.md`: the flags and `B2_LLM_MAX_EDITS_PER_TURN`.
+
+## PR 6: eval (#257)
+
+- Corpus prompts: ten "clean up / tighten / fix headings" tasks on existing eval notes, each
+  with the expected preserved set (wikilinks, headings, frontmatter keys) and a short rubric.
+- Harness: run `b2 ask --edit --json --note` per task per model tier, apply all proposals to a
+  temp copy, score: clean match rate, preservation, judged quality, honesty (two tasks with
+  nothing to fix must yield zero proposals). Plus the #253 focus case.
+- Record the run in `docs/evals.md`; the result decides the default for edit mode.
+
+## Order and gates
+
+| PR | Issue | Gate before merge |
+|---|---|---|
+| 1 | #253 | `make ci`; no-focus request byte-identical; "clean up this doc" answers about the open note in the app |
+| 2 | #258 | `make ci`; cap and ambiguity tests fail on a broken input first |
+| 3 | #256 | `make ci`; vault-hash-unchanged test; undo rules 1 to 3 |
+| 4 | #259 | `make ci`; browser mock run; `make app` with the fake provider end to end |
+| 5 | #260 | `make ci`; CLI fake-provider tests |
+| 6 | #257 | harness run recorded; default-mode decision written into #255 |
+
+PRs 2 and 5's arg parsing can proceed in parallel with 1; 3 needs 1 and 2; 4 and 5 need 3.