#47 (SLICE-2, review): per-block document proposals + word-precise accept

Document-edit flow SLICE-2 (specs/coauthoring-document-edit-flow.md §7.2,
INV-39/40/41 — P1 "too much to review"). A whole-document rewrite now proposes
ONE F4 proposal per CHANGED BLOCK (the unit a human reviews), but accepting a
block reconciles attribution at WORD granularity (the unit F3 records). Block =
decision unit; word = attribution unit. Supersedes INV-37's per-word cut for
document edits; selection edits unchanged.

- trackChangesModel.ts: new pure diffToBlockHunks(current, rewritten) — block-key
  alignment (reusing diffArrays/diffBlocks keying): an isolated changed block →
  one block-aligned hunk → the rewritten block raw (a code/mermaid fence is one
  atomic whole-fence hunk, INV-23); insert/delete runs → one gap-span hunk over
  the inter-anchor region (separators included) so reconstruction stays exact; a
  zero-width gap-span is anchored (INV-41). Also split diffToHunks into the raw,
  un-anchored wordEditHunks + the anchoring wrapper (the anchoring could grow an
  insertion to overlap an adjacent hunk, corrupting a batch apply — a latent bug
  that only surfaced once hunks are applied as a batch).
- trackChangesPreview.ts: runEditAndPropose document branch uses diffToBlockHunks
  and tags each proposal granularity:"block".
- model.ts / proposalModel.ts: additive optional Proposal.granularity
  ("block"|"single"; absent ⇒ single, back-compat — no migration).
- proposalController.ts: accept of a block proposal runs an intra-block word
  sub-diff (wordEditHunks, disjoint) and applies one applyAgentEdit per changed
  run, descending offset — only the words Claude changed land Claude-attributed;
  unchanged spans keep prior authorship (INV-40).
- Tests: diffToBlockHunks unit (reconstruction + fence atomic + add/remove);
  f12Review host E2E (M blocks→M proposals, unchanged→none, fence atomic, INV-40
  attribution, INV-41 insertion accept); updated the f11 document-path E2E to
  per-block (INV-39 supersedes INV-37); MANUAL-SMOKE-F12 §2.

Seam note: pendingEdits.matchEvent resolves one registration per change event, so
INV-40's per-run attribution is sequential applyAgentEdit calls (N undo steps),
not one multi-replace WorkspaceEdit — see transcript Deferred decisions.

214 unit + 69/5 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-13 08:01:16 -07:00
parent 6e944ab4cc
commit 2f6008ba2b
9 changed files with 456 additions and 26 deletions
+7
View File
@@ -90,6 +90,13 @@ export interface Proposal {
turnId?: string;
/** what the human asked for (review context). */
instruction?: string;
/**
* F12/#47 (INV-39/40): the review-decision unit this proposal represents.
* `"block"` ⇒ the anchor spans a whole document block and accept reconciles
* attribution per WORD inside it (INV-40); `"single"` (or absent, for
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
*/
granularity?: "block" | "single";
}
export interface Artifact {
+50 -8
View File
@@ -18,7 +18,7 @@ import { addProposal, removeProposal } from "./proposalModel";
import type { AttributionController } from "./attributionController";
import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath";
import type { ProposalView } from "./trackChangesModel";
import { wordEditHunks, type ProposalView } from "./trackChangesModel";
/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedProposal {
@@ -122,7 +122,7 @@ export class ProposalController implements vscode.Disposable {
fp: Fingerprint,
replacement: string,
author: Provenance,
opts?: { turnId?: string; instruction?: string },
opts?: { turnId?: string; instruction?: string; granularity?: "block" | "single" },
): Promise<string | undefined> {
if (!this.isTracked(document)) return undefined;
if (this.guard.isReadOnly(this.keyOf(document))) return undefined;
@@ -164,12 +164,19 @@ export class ProposalController implements vscode.Disposable {
this.renderAll(document);
return false;
}
const range = new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end));
// No awaits between resolve and the seam call: document.version is current.
const ok = await this.attribution.applyAgentEdit(document, range, proposal.replacement, proposal.author, {
expectedVersion: document.version,
turnId: proposal.turnId,
});
// #47 (INV-40): a BLOCK proposal applies the whole block but attributes only
// the words Claude actually changed; a single proposal applies its whole range.
const ok =
proposal.granularity === "block"
? await this.acceptBlock(document, resolved, proposal)
: await this.attribution.applyAgentEdit(
document,
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
proposal.replacement,
proposal.author,
// No awaits between resolve and the seam call: document.version is current.
{ expectedVersion: document.version, turnId: proposal.turnId },
);
if (!ok) {
void vscode.window.showWarningMessage(
"Cowriting: the editor rejected the accept — the proposal is still pending.",
@@ -181,6 +188,41 @@ export class ProposalController implements vscode.Disposable {
return true;
}
/**
* #47 (INV-40): accept a BLOCK proposal — apply Claude's whole block, but
* attribute ONLY the runs Claude actually changed. The block is the DECISION
* unit; the word is the ATTRIBUTION unit. An intra-block word sub-diff
* (`wordEditHunks`, the raw engine INV-37 used, repurposed — un-anchored so the
* runs stay disjoint for a batch apply) yields the changed runs; each lands
* through the F4 seam (Claude-attributed), applied last-position-first so an
* earlier run's offsets stay valid under the later ones. Unchanged spans within
* the block are never touched, so their prior authorship stands. (Each run is
* one seam edit / one undo step — see the spec's deferred note on undo grouping.)
*/
private async acceptBlock(
document: vscode.TextDocument,
resolved: OffsetRange,
proposal: Proposal,
): Promise<boolean> {
const blockText = document.getText(
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
);
const subHunks = wordEditHunks(blockText, proposal.replacement);
if (subHunks.length === 0) return true; // block already equals the proposal — nothing to attribute
for (const h of [...subHunks].sort((a, b) => b.start - a.start)) {
const range = new vscode.Range(
document.positionAt(resolved.start + h.start),
document.positionAt(resolved.start + h.end),
);
const ok = await this.attribution.applyAgentEdit(document, range, h.replacement, proposal.author, {
expectedVersion: document.version,
turnId: proposal.turnId,
});
if (!ok) return false;
}
return true;
}
private reject(state: DocState, proposal: Proposal): void {
if (this.guard.isReadOnly(state.docPath)) return;
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
+2 -1
View File
@@ -12,7 +12,7 @@ export function addProposal(
fp: Fingerprint,
replacement: string,
author: Provenance,
opts?: { turnId?: string; instruction?: string },
opts?: { turnId?: string; instruction?: string; granularity?: "block" | "single" },
): { proposalId: string; anchorId: string } {
const anchorId = newId("a");
const proposalId = newId("pr");
@@ -25,6 +25,7 @@ export function addProposal(
createdAt: new Date().toISOString(),
...(opts?.turnId !== undefined ? { turnId: opts.turnId } : {}),
...(opts?.instruction !== undefined ? { instruction: opts.instruction } : {}),
...(opts?.granularity !== undefined ? { granularity: opts.granularity } : {}),
});
return { proposalId, anchorId };
}
+119 -1
View File
@@ -223,11 +223,27 @@ export interface EditHunk {
* never part of another hunk.
*/
export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] {
return wordEditHunks(currentText, rewrittenText).map((h) =>
h.start === h.end ? anchorInsertion(h, currentText) : h,
);
}
/**
* The raw word-level diff underlying `diffToHunks`: disjoint, ordered EditHunks
* that exactly partition the change (a pure insertion stays ZERO-WIDTH at its
* offset — not anchored). `diffToHunks` adds anchoring on top so each hunk's F4
* fingerprint resolves; callers that apply hunks directly through the seam
* (INV-40's intra-block accept) want THESE raw, non-overlapping hunks instead —
* anchoring can grow an insertion to absorb a token an adjacent hunk also edits,
* which corrupts a batch apply. Pure, vscode-free, deterministic. Applying these
* right→left reconstructs `rewrittenText` exactly.
*/
export function wordEditHunks(currentText: string, rewrittenText: string): EditHunk[] {
const hunks: EditHunk[] = [];
let offset = 0;
let open: EditHunk | null = null;
const flush = () => {
if (open) hunks.push(open.start === open.end ? anchorInsertion(open, currentText) : open);
if (open) hunks.push(open);
open = null;
};
for (const part of diffWordsWithSpace(currentText, rewrittenText)) {
@@ -272,6 +288,108 @@ function anchorInsertion(hunk: EditHunk, text: string): EditHunk {
return { start: s, end: p, replacement: text.slice(s, p) + hunk.replacement };
}
type BlockAlignKind = "unchanged" | "changed" | "removed" | "added";
interface BlockAlignOp {
kind: BlockAlignKind;
ci?: number; // index into the current blocks
ni?: number; // index into the rewritten blocks
}
/**
* Align two block sequences by normalized key (jsdiff `diffArrays`), pairing an
* adjacent removed-then-added run element-wise into `changed` ops (the surplus
* staying `removed` / `added`) — the same pairing `diffBlocks` uses, but yielding
* index ops so the caller can read each side's source range. Pure.
*/
function alignBlocks(cur: BlockWithRange[], next: BlockWithRange[]): BlockAlignOp[] {
const changes = diffArrays(
cur.map((b) => b.key),
next.map((b) => b.key),
);
const ops: BlockAlignOp[] = [];
let bi = 0; // current index
let ci = 0; // rewritten index
for (let n = 0; n < changes.length; n++) {
const ch = changes[n];
const count = ch.count ?? ch.value.length;
if (!ch.added && !ch.removed) {
for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", ci: bi++, ni: ci++ });
continue;
}
if (ch.removed) {
const nx = changes[n + 1];
const addCount = nx?.added ? (nx.count ?? nx.value.length) : 0;
const paired = Math.min(count, addCount);
for (let k = 0; k < paired; k++) ops.push({ kind: "changed", ci: bi++, ni: ci++ });
for (let k = paired; k < count; k++) ops.push({ kind: "removed", ci: bi++ });
for (let k = paired; k < addCount; k++) ops.push({ kind: "added", ni: ci++ });
if (nx?.added) n++;
continue;
}
for (let k = 0; k < count; k++) ops.push({ kind: "added", ni: ci++ });
}
return ops;
}
/**
* #47 (INV-39, §6.4): diff a whole-document rewrite into ONE EditHunk per CHANGED
* BLOCK — the unit a human reviews — rather than per word (which INV-37/`diffToHunks`
* did and this supersedes for document edits). Built by aligning both sides into
* the existing block units (`splitBlocksWithRanges`) and keying with the same diff
* as `diffBlocks`:
* - an ISOLATED changed block (1:1, unchanged blocks on both sides) → a
* block-aligned hunk `[block.start, block.end)` → the rewritten block's raw
* text. A code/mermaid fence is one such whole-block hunk (atomic, INV-23).
* - any other changed RUN (block insertions/deletions, or adjacent changes) → one
* gap-span hunk covering the source between the bounding unchanged blocks → the
* matching rewritten span (separators included), so reconstruction stays exact.
* A zero-width gap-span (insert at a seamless boundary) is anchored to an
* adjacent token (INV-41) so its F4 fingerprint resolves and it accepts.
* - unchanged blocks (same key AND same raw) → no hunk.
* Pure, vscode-free, deterministic; same `EditHunk` shape as `diffToHunks` (which
* is RETAINED as the intra-block sub-diff engine for word-precise accept
* attribution — INV-40). Applying all hunks right→left reconstructs `rewrittenText`
* exactly.
*/
export function diffToBlockHunks(currentText: string, rewrittenText: string): EditHunk[] {
const cur = splitBlocksWithRanges(currentText);
const next = splitBlocksWithRanges(rewrittenText);
const ops = alignBlocks(cur, next);
// Same key but different raw (whitespace/case) is still a real content change.
for (const op of ops) {
if (op.kind === "unchanged" && cur[op.ci!].raw !== next[op.ni!].raw) op.kind = "changed";
}
const hunks: EditHunk[] = [];
let i = 0;
while (i < ops.length) {
if (ops[i].kind === "unchanged") {
i++;
continue;
}
let j = i;
while (j < ops.length && ops[j].kind !== "unchanged") j++;
const run = ops.slice(i, j);
if (run.length === 1 && run[0].kind === "changed") {
const c = cur[run[0].ci!];
hunks.push({ start: c.start, end: c.end, replacement: next[run[0].ni!].raw });
} else {
// A run with insertions/deletions: replace the whole inter-anchor gap so the
// separators reconstruct exactly. Bound by the unchanged blocks on each side
// (or the document edges).
const prev = i > 0 ? ops[i - 1] : null;
const after = j < ops.length ? ops[j] : null;
const curStart = prev ? cur[prev.ci!].end : 0;
const curEnd = after ? cur[after.ci!].start : currentText.length;
const newStart = prev ? next[prev.ni!].end : 0;
const newEnd = after ? next[after.ni!].start : rewrittenText.length;
const hunk: EditHunk = { start: curStart, end: curEnd, replacement: rewrittenText.slice(newStart, newEnd) };
hunks.push(hunk.start === hunk.end ? anchorInsertion(hunk, currentText) : hunk);
}
i = j;
}
return hunks;
}
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
// other fences fall through to markdown-it's default (escaped <pre><code>).
+16 -7
View File
@@ -13,7 +13,7 @@ import * as vscode from "vscode";
import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController";
import type { ProposalController } from "./proposalController";
import { renderReview, renderPlain, diffBlocks, diffToHunks, type BlockOp } from "./trackChangesModel";
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath";
import type { EditTurnResult } from "./liveTurn";
@@ -226,11 +226,12 @@ export class TrackChangesPreviewController implements vscode.Disposable {
}
/**
* F11 (INV-35/37): run one host edit turn and record the result as F4
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
* proposal(s) a SELECTION yields one single-range proposal over the resolved
* block-union; a DOCUMENT rewrite is `diffToHunks`'d into one single-range
* proposal per changed hunk (reusing the F4 single-range model N times, no new
* model). Never mutates the document (INV-10). Returns the created proposal ids.
* block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per
* changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged
* `granularity:"block"` so accept reconciles attribution per word (INV-40).
* Never mutates the document (INV-10). Returns the created proposal ids.
*/
async runEditAndPropose(
document: vscode.TextDocument,
@@ -253,9 +254,17 @@ export class TrackChangesPreviewController implements vscode.Disposable {
}
const turn = await this.editTurn(instruction, full);
const ids: string[] = [];
for (const h of diffToHunks(full, turn.replacement)) {
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
// granularity — one proposal per changed block (the unit a human reviews) —
// not per word. Each is tagged `granularity:"block"` so accept reconciles
// attribution per word inside the block (INV-40).
for (const h of diffToBlockHunks(full, turn.replacement)) {
const fp = buildFingerprint(full, { start: h.start, end: h.end });
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), { turnId, instruction });
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), {
turnId,
instruction,
granularity: "block",
});
if (id) ids.push(id);
}
return ids;