From debd09fb97a301eef32c1eb2168c64889a131497 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:00:42 -0700 Subject: [PATCH 1/9] =?UTF-8?q?F4:=20add=20implementation=20plan=20(just-i?= =?UTF-8?q?n-time=20from=20coauthoring-propose-accept=20spec=20=C2=A77.2)?= =?UTF-8?q?=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-10-f4-propose-accept.md | 1160 +++++++++++++++++ 1 file changed, 1160 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-f4-propose-accept.md diff --git a/docs/superpowers/plans/2026-06-10-f4-propose-accept.md b/docs/superpowers/plans/2026-06-10-f4-propose-accept.md new file mode 100644 index 0000000..1a008a5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-f4-propose-accept.md @@ -0,0 +1,1160 @@ +# F4 Propose/Accept Diff Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Claude's edits arrive as pending, anchored, diff-rendered **proposals** the human accepts (applies via the `applyAgentEdit` seam → Claude-attributed) or rejects (document untouched), persisted git-natively in the sidecar's reserved `proposals[]`. + +**Architecture:** One new vscode-free model slice (`Proposal` type + `proposalModel.ts` helpers) and one new editor-facing controller (`ProposalController`: second Comments controller `cowriting.proposals` with fenced-diff bodies + ✓/✗ title actions, amber pending-range decoration, resolve-or-flag ladder). The seam, AttributionController, Store, and Anchorer are reused unchanged except the store's anchor-prune learns proposal anchorIds. `editSelection` flips propose-by-default. Spec: `vscode-cowriting-plugin-content/specs/coauthoring-propose-accept.md` (graduated); Feature `#12`. + +**Tech Stack:** TypeScript VS Code extension; vitest (unit, vscode-free); `@vscode/test-electron` + mocha (host E2E, no LLM); esbuild. + +**Load-bearing invariants (spec §6.1):** INV-10 a proposal never mutates the document; INV-11 accept is fingerprint-guarded (exact-text resolve at decision time; stale/orphaned never applied — and a proposal's anchor is **immutable for its life**: proposals are NOT re-fingerprinted on save, unlike threads); INV-12 accept/reject are human-only; INV-13 `proposals[]` is pending-only. + +--- + +### Task 1: Typed `Proposal` model + stable serialization + +**Files:** +- Modify: `src/model.ts` (replace `proposals: unknown[]` with typed array; serialize) +- Test: `test/proposalModel.test.ts` (new) + +- [ ] **Step 1: Write the failing round-trip test** + +```typescript +// test/proposalModel.test.ts +import { describe, expect, it } from "vitest"; +import { + emptyArtifact, + serializeArtifact, + type Artifact, + type Proposal, +} from "../src/model"; + +const agent = { + kind: "agent" as const, + id: "claude", + agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "s1" }, +}; + +describe("Proposal model (spec §6.3, INV-13)", () => { + it("serializes proposals with stable field order and survives a round-trip", () => { + const a = emptyArtifact("docs/x.md"); + a.anchors["a_1"] = { + fingerprint: { text: "old text", before: "", after: "", lineHint: 0 }, + }; + const p: Proposal = { + id: "pr_1", + anchorId: "a_1", + replacement: "new text", + author: agent, + createdAt: "2026-06-10T00:00:00.000Z", + turnId: "turn-1", + instruction: "tighten", + }; + a.proposals.push(p); + const reloaded = JSON.parse(serializeArtifact(a)) as Artifact; + expect(reloaded.proposals).toHaveLength(1); + expect(reloaded.proposals[0]).toEqual(p); + // optional fields are OMITTED (not null) when absent + const bare: Proposal = { + id: "pr_2", anchorId: "a_1", replacement: "r", author: agent, + createdAt: "2026-06-10T00:00:00.000Z", + }; + a.proposals.push(bare); + const again = JSON.parse(serializeArtifact(a)) as Artifact; + expect("turnId" in again.proposals[1]).toBe(false); + expect("instruction" in again.proposals[1]).toBe(false); + // serialization is byte-stable (INV-2) + expect(serializeArtifact(again)).toBe(serializeArtifact(a)); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run test/proposalModel.test.ts` +Expected: FAIL — `Proposal` is not exported / `toEqual` mismatch (proposals pass through untyped today, so the import of `Proposal` fails compilation). + +- [ ] **Step 3: Implement the typed model** + +In `src/model.ts`, after the `AttributionRecord` interface, add: + +```typescript +/** F4 (spec §6.3, INV-13): a PENDING machine edit — state, not history. */ +export interface Proposal { + id: string; + /** shared anchors map; fingerprint.text IS the exact target text (INV-11). */ + anchorId: string; + /** the full proposed text for the anchored range. */ + replacement: string; + author: Provenance; + /** ISO-8601. */ + createdAt: string; + /** groups N proposals born of one live turn. */ + turnId?: string; + /** what the human asked for (review context). */ + instruction?: string; +} +``` + +Change the `Artifact` field (and its comment) from `proposals: unknown[]` to: + +```typescript + /** F4 (spec §6.3, INV-13): pending proposals on shared anchors[] + Provenance. */ + proposals: Proposal[]; +``` + +In `serializeArtifact`, replace `proposals: a.proposals,` with: + +```typescript + proposals: a.proposals.map((p) => ({ + id: p.id, + anchorId: p.anchorId, + replacement: p.replacement, + author: serializeProvenance(p.author), + createdAt: p.createdAt, + ...(p.turnId !== undefined ? { turnId: p.turnId } : {}), + ...(p.instruction !== undefined ? { instruction: p.instruction } : {}), + })), +``` + +`SCHEMA_VERSION` stays `1` (additive fill of a reserved extension point — F3 precedent). `emptyArtifact` is already correct. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run test/proposalModel.test.ts` +Expected: PASS + +- [ ] **Step 5: Typecheck + full unit suite (no regressions)** + +Run: `npm run typecheck && npm test` +Expected: clean; all existing tests still pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/model.ts test/proposalModel.test.ts +git commit -m "F4 SLICE-1: typed proposals[] on shared anchors/Provenance (spec §6.3, INV-13) (#12)" +``` + +--- + +### Task 2: Store anchor-prune learns proposal anchors + +**Files:** +- Modify: `src/store.ts:63-77` (the `update` prune) +- Test: `test/proposalModel.test.ts` (append) + +- [ ] **Step 1: Write the failing prune test** + +Append to `test/proposalModel.test.ts`: + +```typescript +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CoauthorStore } from "../src/store"; + +describe("CoauthorStore prune with proposals (spec §6.3)", () => { + it("retains anchors referenced only by proposals; prunes them after removal", () => { + const dir = mkdtempSync(join(tmpdir(), "cowriting-prune-")); + try { + const store = new CoauthorStore(dir); + store.update("docs/x.md", (a) => { + a.anchors["a_p"] = { + fingerprint: { text: "t", before: "", after: "", lineHint: 0 }, + }; + a.proposals.push({ + id: "pr_1", anchorId: "a_p", replacement: "r", author: agent, + createdAt: "2026-06-10T00:00:00.000Z", + }); + }); + expect(store.load("docs/x.md")!.anchors["a_p"]).toBeDefined(); + store.update("docs/x.md", (a) => { + a.proposals = a.proposals.filter((p) => p.id !== "pr_1"); + }); + expect(store.load("docs/x.md")!.anchors["a_p"]).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run test/proposalModel.test.ts` +Expected: FAIL — `anchors["a_p"]` is `undefined` after the FIRST update (today's prune doesn't count proposals, so the anchor is pruned immediately). + +- [ ] **Step 3: Extend the prune** + +In `src/store.ts` `update()`, replace the `referenced` set construction with: + +```typescript + const referenced = new Set([ + ...artifact.threads.map((t) => t.anchorId), + ...artifact.attributions.map((a) => a.anchorId), + ...artifact.proposals.map((p) => p.anchorId), + ]); +``` + +Also update the method's doc comment: change "(proposals are still empty in F3 — revisit in F4)" to "(threads, attributions, AND proposals all keep their anchors — F4)". + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/proposalModel.test.ts && npm test` +Expected: PASS (all suites). + +- [ ] **Step 5: Commit** + +```bash +git add src/store.ts test/proposalModel.test.ts +git commit -m "F4 SLICE-1: anchor prune counts proposal anchorIds (closes store.ts F4 TODO) (#12)" +``` + +--- + +### Task 3: `proposalModel.ts` pure helpers (add / remove / diff body) + +**Files:** +- Create: `src/proposalModel.ts` +- Test: `test/proposalModel.test.ts` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `test/proposalModel.test.ts`: + +```typescript +import { addProposal, proposalBody, removeProposal } from "../src/proposalModel"; + +describe("proposalModel helpers (spec §6.4)", () => { + const fp = { text: "old line one\nold line two", before: "", after: "", lineHint: 3 }; + + it("addProposal creates the anchor + pending record; removeProposal deletes by id", () => { + const a = emptyArtifact("docs/x.md"); + const { proposalId, anchorId } = addProposal(a, fp, "new line", agent, { + turnId: "turn-9", instruction: "shorten", + }); + expect(a.anchors[anchorId].fingerprint).toEqual(fp); + expect(a.proposals).toHaveLength(1); + expect(a.proposals[0].id).toBe(proposalId); + expect(a.proposals[0].replacement).toBe("new line"); + expect(a.proposals[0].turnId).toBe("turn-9"); + expect(a.proposals[0].instruction).toBe("shorten"); + expect(removeProposal(a, proposalId)).toBe(true); + expect(a.proposals).toHaveLength(0); + expect(removeProposal(a, proposalId)).toBe(false); + }); + + it("proposalBody renders instruction + a fenced unified diff of old → new", () => { + const a = emptyArtifact("docs/x.md"); + addProposal(a, fp, "new only line", agent, { instruction: "merge the lines" }); + const body = proposalBody(fp.text, a.proposals[0]); + expect(body).toContain("**Claude proposes** — _merge the lines_"); + expect(body).toContain("```diff"); + expect(body).toContain("- old line one"); + expect(body).toContain("- old line two"); + expect(body).toContain("+ new only line"); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run test/proposalModel.test.ts` +Expected: FAIL — module `../src/proposalModel` not found. + +- [ ] **Step 3: Implement `src/proposalModel.ts`** + +```typescript +/** + * proposalModel — pure helpers over the F4 `proposals[]` section (spec §6.3, + * INV-13: pending-only). Mirrors threadModel.ts: vscode-free mutations against + * an Artifact, used inside CoauthorStore.update() by ProposalController. + * The diff body is PRESENTATION ONLY — the truth is fingerprint.text → + * replacement (INV-11); a whole-range -/+ rendering, not an LCS diff (§6.7). + */ +import { newId, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model"; + +export function addProposal( + artifact: Artifact, + fp: Fingerprint, + replacement: string, + author: Provenance, + opts?: { turnId?: string; instruction?: string }, +): { proposalId: string; anchorId: string } { + const anchorId = newId("a"); + const proposalId = newId("pr"); + artifact.anchors[anchorId] = { fingerprint: fp }; + artifact.proposals.push({ + id: proposalId, + anchorId, + replacement, + author, + createdAt: new Date().toISOString(), + ...(opts?.turnId !== undefined ? { turnId: opts.turnId } : {}), + ...(opts?.instruction !== undefined ? { instruction: opts.instruction } : {}), + }); + return { proposalId, anchorId }; +} + +/** Remove a pending proposal (accept and reject both end here — INV-13). */ +export function removeProposal(artifact: Artifact, proposalId: string): boolean { + const before = artifact.proposals.length; + artifact.proposals = artifact.proposals.filter((p) => p.id !== proposalId); + return artifact.proposals.length < before; +} + +/** Markdown comment body: instruction header + fenced whole-range diff. */ +export function proposalBody(targetText: string, p: Proposal): string { + const header = p.instruction ? `**Claude proposes** — _${p.instruction}_` : "**Claude proposes**"; + const diffLines = [ + ...targetText.split("\n").map((l) => `- ${l}`), + ...p.replacement.split("\n").map((l) => `+ ${l}`), + ].join("\n"); + return `${header}\n\n\`\`\`diff\n${diffLines}\n\`\`\`\n\n✓ Accept applies this replacement · ✗ Reject discards it`; +} +``` + +- [ ] **Step 4: Run tests + typecheck** + +Run: `npx vitest run test/proposalModel.test.ts && npm run typecheck` +Expected: PASS / clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/proposalModel.ts test/proposalModel.test.ts +git commit -m "F4 SLICE-1: proposalModel helpers — add/remove + fenced-diff body (#12)" +``` + +--- + +### Task 4: `ProposalController` (propose · render · live-shift · accept · reject) + +**Files:** +- Create: `src/proposalController.ts` + +This is the editor-facing layer — verified by typecheck/build now, host E2E in Task 7. Key behavioral notes baked into the code: proposals are persisted **at propose time**; their anchors are **never re-fingerprinted** (INV-11 — deliberate deviation from ThreadController's `onDidSave`); accept re-resolves at decision time and refuses on "orphaned"; the seam call carries `expectedVersion: document.version` captured synchronously. + +- [ ] **Step 1: Write `src/proposalController.ts`** + +```typescript +/** + * ProposalController — the editor-facing layer for F4 (spec + * coauthoring-propose-accept §6.2). Owns the proposal lifecycle: the propose + * ingress (INV-10: NEVER mutates the document), persistence at propose time, + * resolve-or-flag on load/external change (INV-11 — a proposal's anchor is + * immutable for its life: no save-time re-fingerprint, unlike threads), + * rendering (second Comments controller + amber pending-range decoration), + * and the human-only accept/reject gestures (INV-12). Accept drives the seam + * (AttributionController.applyAgentEdit, INV-9) so accepted text lands + * Claude-attributed with zero new attribution code. + */ +import * as vscode from "vscode"; +import { CoauthorStore } from "./store"; +import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model"; +import { resolve, shift, type OffsetRange } from "./anchorer"; +import { addProposal, proposalBody, removeProposal } from "./proposalModel"; +import type { AttributionController } from "./attributionController"; + +/** Test-facing snapshot of what is currently rendered for a document. */ +export interface RenderedProposal { + id: string; + /** true → resolves exactly, decidable; false → stale/orphaned (INV-11). */ + pending: boolean; + turnId?: string; + range: { start: number; end: number }; +} + +interface DocState { + docPath: string; + uri: vscode.Uri; + artifact: Artifact; + vsThreads: Map; + /** proposal id -> live offset range (within-session optimization, INV-3). */ + live: Map; + /** proposal ids whose anchor did not resolve at last render (stale/orphaned). */ + unresolved: Set; +} + +const PENDING_DECO: vscode.DecorationRenderOptions = { + backgroundColor: "rgba(245, 158, 11, 0.18)", + overviewRulerColor: "rgba(245, 158, 11, 0.8)", + overviewRulerLane: vscode.OverviewRulerLane.Right, +}; + +export class ProposalController implements vscode.Disposable { + private readonly controller: vscode.CommentController; + private readonly disposables: vscode.Disposable[] = []; + private readonly docs = new Map(); // keyed by docPath + private readonly pendingType = vscode.window.createTextEditorDecorationType(PENDING_DECO); + private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 89); + + constructor( + private readonly store: CoauthorStore, + private readonly attribution: AttributionController, + private readonly rootDir: string, + ) { + // No commentingRangeProvider: humans never open proposal threads by hand — + // proposals are born of machine turns only (INV-12 keeps decisions human). + this.controller = vscode.comments.createCommentController("cowriting.proposals", "Claude Proposals"); + this.disposables.push(this.controller, this.pendingType, this.statusItem); + this.disposables.push( + vscode.commands.registerCommand("cowriting.acceptProposal", (t: vscode.CommentThread) => this.acceptThread(t)), + vscode.commands.registerCommand("cowriting.rejectProposal", (t: vscode.CommentThread) => this.rejectThread(t)), + vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), + ); + } + + private isTracked(document: vscode.TextDocument): boolean { + return document.uri.scheme === "file" && document.uri.fsPath.startsWith(this.rootDir); + } + private docPathOf(uri: vscode.Uri): string { + return vscode.workspace.asRelativePath(uri, false); + } + private ensureState(document: vscode.TextDocument): DocState { + const docPath = this.docPathOf(document.uri); + let state = this.docs.get(docPath); + if (!state) { + state = { + docPath, + uri: document.uri, + artifact: this.store.load(docPath) ?? emptyArtifact(docPath), + vsThreads: new Map(), + live: new Map(), + unresolved: new Set(), + }; + this.docs.set(docPath, state); + } + return state; + } + + // ---- PUC-1: the propose ingress (INV-10) ----------------------------------------- + + /** + * Record + render a pending proposal. The fingerprint is built by the CALLER + * at gesture time (editSelection captures it BEFORE the turn, so mid-turn + * edits can't skew the anchor). Never touches the document. + */ + async propose( + document: vscode.TextDocument, + fp: Fingerprint, + replacement: string, + author: Provenance, + opts?: { turnId?: string; instruction?: string }, + ): Promise { + if (!this.isTracked(document)) return undefined; + const docPath = this.docPathOf(document.uri); + let proposalId: string | undefined; + this.store.update(docPath, (a) => { + proposalId = addProposal(a, fp, replacement, author, opts).proposalId; + }); + this.renderAll(document); + return proposalId; + } + + // ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ---------------------------------- + + /** Accept by proposal id (test-facing twin of the thread-menu gesture). */ + async acceptById(docPath: string, proposalId: string): Promise { + const hit = this.byId(docPath, proposalId); + return hit ? this.accept(hit.state, hit.proposal) : false; + } + /** Reject by proposal id (test-facing twin of the thread-menu gesture). */ + rejectById(docPath: string, proposalId: string): boolean { + const hit = this.byId(docPath, proposalId); + if (!hit) return false; + this.reject(hit.state, hit.proposal); + return true; + } + + private async acceptThread(vsThread: vscode.CommentThread): Promise { + const hit = this.byThread(vsThread); + if (hit) await this.accept(hit.state, hit.proposal); + } + private rejectThread(vsThread: vscode.CommentThread): void { + const hit = this.byThread(vsThread); + if (hit) this.reject(hit.state, hit.proposal); + } + + private async accept(state: DocState, proposal: Proposal): Promise { + const document = this.openDoc(state); + if (!document) return false; + // INV-11: the fingerprint-guard — exact re-resolve at decision time. + const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; + const resolved = fp ? resolve(document.getText(), fp) : "orphaned"; + if (resolved === "orphaned") { + void vscode.window.showWarningMessage( + "Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).", + ); + 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, + }); + if (!ok) { + void vscode.window.showWarningMessage( + "Cowriting: the editor rejected the accept — the proposal is still pending.", + ); + return false; + } + this.store.update(state.docPath, (a) => removeProposal(a, proposal.id)); + this.renderAll(document); + return true; + } + + private reject(state: DocState, proposal: Proposal): void { + this.store.update(state.docPath, (a) => removeProposal(a, proposal.id)); + const document = this.openDoc(state); + if (document) this.renderAll(document); + } + + // ---- PUC-4: load / external change / resolve-or-flag -------------------------------- + + /** Load + (re)render every pending proposal at its resolved anchor (or flagged). */ + renderAll(document: vscode.TextDocument): void { + if (!this.isTracked(document)) return; + const docPath = this.docPathOf(document.uri); + const state = this.ensureState(document); + state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); + for (const vsThread of state.vsThreads.values()) vsThread.dispose(); + state.vsThreads.clear(); + state.live.clear(); + state.unresolved.clear(); + const text = document.getText(); + for (const proposal of state.artifact.proposals) { + const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; + const resolved = fp ? resolve(text, fp) : "orphaned"; + if (resolved === "orphaned") { + const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0; + const off = document.offsetAt(new vscode.Position(line, 0)); + this.renderProposal(document, state, proposal, { start: off, end: off }, false); + } else { + this.renderProposal(document, state, proposal, resolved, true); + } + } + this.renderDecorations(document, state); + this.renderStatus(state); + } + + /** Shared-watcher entry point (extension.ts): a sidecar changed externally. */ + handleExternalSidecarChange(uri: vscode.Uri): void { + for (const state of this.docs.values()) { + if (this.store.sidecarPath(state.docPath) === uri.fsPath) { + const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); + if (doc) this.renderAll(doc); + } + } + } + + private onDidChange(e: vscode.TextDocumentChangeEvent): void { + const state = this.docs.get(this.docPathOf(e.document.uri)); + if (!state || state.live.size === 0) return; + for (const change of e.contentChanges) { + const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length }; + for (const [id, range] of state.live) { + const next = shift(range, edit); + state.live.set(id, next); + const vsThread = state.vsThreads.get(id); + if (vsThread && !state.unresolved.has(id)) { + vsThread.range = new vscode.Range(e.document.positionAt(next.start), e.document.positionAt(next.end)); + } + } + } + // Live shift keeps the UI following; staleness is judged at decision time + // (accept re-resolves, INV-11) and at the next renderAll. + this.renderDecorations(e.document, state); + } + + // ---- rendering ----------------------------------------------------------------------- + + private renderProposal( + document: vscode.TextDocument, + state: DocState, + proposal: Proposal, + offsets: OffsetRange, + pending: boolean, + ): void { + const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; + const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end)); + const vsThread = this.controller.createCommentThread(document.uri, range, [ + { + body: new vscode.MarkdownString(proposalBody(fp?.text ?? "", proposal)), + mode: vscode.CommentMode.Preview, + author: { name: proposal.author.id }, + }, + ]); + vsThread.label = pending ? "Pending proposal" : "⚠ Stale proposal (target text changed or missing) — accept disabled"; + vsThread.contextValue = pending ? "pending" : "unresolved"; + vsThread.collapsibleState = pending + ? vscode.CommentThreadCollapsibleState.Expanded + : vscode.CommentThreadCollapsibleState.Collapsed; + state.vsThreads.set(proposal.id, vsThread); + state.live.set(proposal.id, offsets); + if (!pending) state.unresolved.add(proposal.id); + } + + private renderDecorations(document: vscode.TextDocument, state: DocState): void { + const ranges: vscode.Range[] = []; + for (const [id, off] of state.live) { + if (state.unresolved.has(id)) continue; + ranges.push(new vscode.Range(document.positionAt(off.start), document.positionAt(off.end))); + } + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document === document) editor.setDecorations(this.pendingType, ranges); + } + } + + private renderStatus(state: DocState): void { + const n = state.unresolved.size; + if (n === 0) { + this.statusItem.hide(); + return; + } + this.statusItem.text = `$(warning) ${n} stale proposal${n === 1 ? "" : "s"}`; + this.statusItem.tooltip = "Cowriting: proposals whose target text changed or is missing — undo to restore, or reject"; + this.statusItem.show(); + } + + // ---- lookups ---------------------------------------------------------------------------- + + private openDoc(state: DocState): vscode.TextDocument | undefined { + return vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); + } + private byThread(vsThread: vscode.CommentThread): { state: DocState; proposal: Proposal } | undefined { + for (const state of this.docs.values()) { + for (const [id, t] of state.vsThreads) { + if (t === vsThread) { + const proposal = state.artifact.proposals.find((p) => p.id === id); + if (proposal) return { state, proposal }; + } + } + } + return undefined; + } + private byId(docPath: string, proposalId: string): { state: DocState; proposal: Proposal } | undefined { + const state = this.docs.get(docPath); + const proposal = state?.artifact.proposals.find((p) => p.id === proposalId); + return state && proposal ? { state, proposal } : undefined; + } + + // ---- test-facing surface ------------------------------------------------------------------ + + getRendered(docPath: string): RenderedProposal[] { + const state = this.docs.get(docPath); + if (!state) return []; + const out: RenderedProposal[] = []; + for (const [id] of state.vsThreads) { + const p = state.artifact.proposals.find((x) => x.id === id)!; + const off = state.live.get(id)!; + out.push({ + id, + pending: !state.unresolved.has(id), + turnId: p.turnId, + range: { start: off.start, end: off.end }, + }); + } + return out; + } + getStaleCount(docPath: string): number { + return this.docs.get(docPath)?.unresolved.size ?? 0; + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + } +} +``` + +- [ ] **Step 2: Typecheck + build** + +Run: `npm run typecheck && npm run build` +Expected: clean (the file compiles standalone; nothing imports it yet). + +- [ ] **Step 3: Commit** + +```bash +git add src/proposalController.ts +git commit -m "F4 SLICE-2/3: ProposalController — propose ingress, resolve-or-flag, diff threads, accept/reject via the seam (#12)" +``` + +--- + +### Task 5: Wire it up — `package.json` contributes + `extension.ts` + +**Files:** +- Modify: `package.json` (commands + menus) +- Modify: `src/extension.ts` (controller, watcher fan-out, renderIfOpen, hidden propose command, no-workspace stubs, API export) + +- [ ] **Step 1: Add commands + menus to `package.json`** + +In `contributes.commands`, add (after the `cowriting.editSelection` entry): + +```json + { "command": "cowriting.acceptProposal", "title": "✓ Accept Proposal", "category": "Cowriting" }, + { "command": "cowriting.rejectProposal", "title": "✗ Reject Proposal", "category": "Cowriting" }, + { "command": "cowriting.proposeAgentEdit", "title": "Propose Agent Edit (internal seam)", "category": "Cowriting" } +``` + +In `contributes.menus.commandPalette`, add: + +```json + { "command": "cowriting.proposeAgentEdit", "when": "false" }, + { "command": "cowriting.acceptProposal", "when": "false" }, + { "command": "cowriting.rejectProposal", "when": "false" } +``` + +In `contributes.menus["comments/commentThread/title"]`, add: + +```json + { + "command": "cowriting.acceptProposal", + "group": "inline@1", + "when": "commentController == cowriting.proposals && commentThread =~ /^pending$/" + }, + { + "command": "cowriting.rejectProposal", + "group": "inline@2", + "when": "commentController == cowriting.proposals" + } +``` + +(Accept is offered only on `pending` threads — the menu mirrors the INV-11 guard the code enforces; reject is always available, including for stale ones.) + +- [ ] **Step 2: Wire `extension.ts`** + +Add imports: + +```typescript +import { ProposalController } from "./proposalController"; +import { buildFingerprint } from "./anchorer"; +``` + +Extend the API type: + +```typescript +export interface CowritingApi { + threadController: ThreadController; + attributionController: AttributionController; + proposalController: ProposalController; +} +``` + +In the no-workspace stub list, add the three new commands (every contributed command must exist — #8): + +```typescript + "cowriting.acceptProposal", + "cowriting.rejectProposal", + "cowriting.proposeAgentEdit", +``` + +After the AttributionController construction, add: + +```typescript + // --- F4: propose/accept (Feature #12) --- + const proposalController = new ProposalController(store, attributionController, root); + context.subscriptions.push(proposalController); +``` + +In the shared-watcher `onSidecar` fan-out, add: + +```typescript + proposalController.handleExternalSidecarChange(uri); +``` + +After the `cowriting.applyAgentEdit` command registration, add the propose ingress command (the E2E entry point — INV-10): + +```typescript + // The propose ingress as a command (spec §6.4): records a pending proposal, + // NEVER touches the document (INV-10) — for the host E2E harness. + context.subscriptions.push( + vscode.commands.registerCommand( + "cowriting.proposeAgentEdit", + (args: { + uri: string; start: number; end: number; newText: string; + model?: string; sessionId?: string; turnId?: string; instruction?: string; + }) => { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === args.uri); + if (!doc) return Promise.resolve(undefined); + const fp = buildFingerprint(doc.getText(), { start: args.start, end: args.end }); + const provenance = { + kind: "agent" as const, + id: "claude", + agent: { sdk: "@cline/sdk", model: args.model ?? "sonnet", sessionId: args.sessionId ?? "" }, + }; + return proposalController.propose(doc, fp, args.newText, provenance, { + turnId: args.turnId, + instruction: args.instruction, + }); + }, + ), + ); +``` + +In `renderIfOpen`, add: + +```typescript + proposalController.renderAll(doc); +``` + +And return the extended API: + +```typescript + return { threadController, attributionController, proposalController }; +``` + +- [ ] **Step 3: Typecheck + build + unit suite** + +Run: `npm run typecheck && npm run build && npm test` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add package.json src/extension.ts +git commit -m "F4 SLICE-3: wire ProposalController — contributes, watcher fan-out, propose command, stubs, API (#12)" +``` + +--- + +### Task 6: Flip `editSelection` propose-by-default (SLICE-4) + +**Files:** +- Modify: `src/extension.ts` (the `cowriting.editSelection` handler only) + +- [ ] **Step 1: Retarget the live turn** + +Replace the `cowriting.editSelection` handler body from `const document = editor.document;` down through the end of the `withProgress` block with: + +```typescript + const document = editor.document; + const selection = editor.selection; + const selectedText = document.getText(selection); + // Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits + // can't skew it — the proposal renders wherever the target re-resolves. + const fp = buildFingerprint(document.getText(), { + start: document.offsetAt(selection.start), + end: document.offsetAt(selection.end), + }); + const turnId = `turn-${Date.now().toString(36)}`; + try { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" }, + async () => { + const { runEditTurn } = await import("./liveTurn"); + const turn = await runEditTurn(instruction, selectedText); + if (turn.replacement === "") { + void vscode.window.showWarningMessage( + "Cowriting: Claude returned an empty replacement — nothing was proposed.", + ); + return; + } + if (turn.replacement === selectedText) { + void vscode.window.showInformationMessage( + "Cowriting: Claude proposed no change to the selection.", + ); + return; + } + // F4 (INV-10): the turn ends in a PROPOSAL, not a buffer mutation. + // The seam now fires only on accept (ProposalController, INV-9). + const id = await proposalController.propose( + document, + fp, + turn.replacement, + { + kind: "agent", + id: "claude", + agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId }, + }, + { turnId, instruction }, + ); + if (id) { + void vscode.window.showInformationMessage( + "Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).", + ); + } + }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`); + } +``` + +(The old `expectedVersion` capture is gone — propose applies nothing, so there is no version race at propose time. The dropped variable was only used by the removed `applyAgentEdit` call. The comment above the command registration should also change from "→ the applyAgentEdit seam (INV-9)" to "→ a pending proposal (F4, INV-10); the seam fires on accept".) + +- [ ] **Step 2: Typecheck + build + unit suite** + +Run: `npm run typecheck && npm run build && npm test` +Expected: clean — F3's E2E entry points (`applyAgentEdit` API + command) are untouched; only the live-turn tail moved. + +- [ ] **Step 3: Commit** + +```bash +git add src/extension.ts +git commit -m "F4 SLICE-4: editSelection is propose-by-default — the turn ends in a proposal, the seam fires on accept (#12)" +``` + +--- + +### Task 7: Host E2E (SLICE-5) — propose/accept/reject/persist/stale/coexist, no LLM + +**Files:** +- Create: `test/e2e/fixtures/workspace/docs/proposal.md` +- Create: `test/e2e/suite/proposals.test.ts` + +- [ ] **Step 1: Add the fixture document** (this suite OWNS `docs/proposal.md`; the F2 suite owns `docs/sample.md`, F3 owns `docs/attrib.md` — keep fixtures disjoint) + +```markdown +# Proposal fixture + +A stable opening paragraph. + +The propose target sentence lives here. + +A second target for coexistence checks. + +A stable closing paragraph. +``` + +- [ ] **Step 2: Write `test/e2e/suite/proposals.test.ts`** + +```typescript +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; +import type { Artifact } from "../../../src/model"; + +const WS = process.env.E2E_WORKSPACE!; +const DOC_REL = "docs/proposal.md"; + +function sidecarPath(): string { + return path.join(WS, ".threads", "docs", "proposal.md.json"); +} +function readSidecar(): Artifact { + return JSON.parse(fs.readFileSync(sidecarPath(), "utf8")) as Artifact; +} +async function openDoc(): Promise { + const uri = vscode.Uri.file(path.join(WS, DOC_REL)); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + return doc; +} +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.proposalController, "extension exports proposalController"); + return api; +} +async function proposeViaCommand( + doc: vscode.TextDocument, + target: string, + newText: string, + turnId: string, +): Promise { + const start = doc.getText().indexOf(target); + assert.ok(start >= 0, `fixture contains "${target}"`); + const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { + uri: doc.uri.toString(), + start, + end: start + target.length, + newText, + model: "sonnet", + sessionId: "e2e-prop", + turnId, + instruction: "e2e instruction", + }); + assert.ok(id, "propose returns the proposal id"); + return id!; +} +async function externalWriteAndReload(uri: vscode.Uri, content: string): Promise { + fs.writeFileSync(uri.fsPath, content, "utf8"); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("workbench.action.files.revert"); + return doc; +} +const settle = () => new Promise((r) => setTimeout(r, 300)); + +// Tests are ORDER-DEPENDENT (F2/F3 pattern): later tests consume earlier state. +suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => { + const TARGET = "The propose target sentence lives here."; + const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence."; + + test("propose records + renders a pending proposal and does NOT touch the document (INV-10)", async () => { + const doc = await openDoc(); + const api = await getApi(); + const before = doc.getText(); + await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1"); + await settle(); + assert.strictEqual(doc.getText(), before, "document text unchanged (INV-10)"); + const rendered = api.proposalController.getRendered(DOC_REL); + assert.strictEqual(rendered.length, 1); + assert.strictEqual(rendered[0].pending, true); + assert.strictEqual(rendered[0].turnId, "turn-p1"); + const art = readSidecar(); + assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time"); + assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, TARGET); + }); + + test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => { + const doc = await openDoc(); + const api = await getApi(); + const id = api.proposalController.getRendered(DOC_REL)[0].id; + const ok = await api.proposalController.acceptById(DOC_REL, id); + assert.strictEqual(ok, true, "accept applies"); + await settle(); + assert.ok(doc.getText().includes(REPLACEMENT), "replacement landed"); + assert.ok(!doc.getText().includes(TARGET), "target gone"); + const spans = api.attributionController.getSpans(DOC_REL); + const agent = spans.find((s) => s.turnId === "turn-p1"); + assert.ok(agent, "accepted text is Claude-attributed with the proposal's turnId"); + assert.strictEqual(agent!.authorKind, "agent"); + assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0, "proposal gone (INV-13)"); + assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar"); + }); + + test("reject leaves the document untouched and removes the proposal (PUC-3)", async () => { + const doc = await openDoc(); + const api = await getApi(); + const before = doc.getText(); + const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2"); + await settle(); + assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true); + await settle(); + assert.strictEqual(doc.getText(), before, "document untouched"); + assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0); + assert.strictEqual(readSidecar().proposals.length, 0); + }); + + test("a pending proposal persists, survives reload, and re-anchors after an external move (PUC-4)", async () => { + let doc = await openDoc(); + const api = await getApi(); + const anchor = "A stable closing paragraph."; + await proposeViaCommand(doc, anchor, "A PROPOSED closing paragraph.", "turn-p3"); + await settle(); + const uri = vscode.Uri.file(path.join(WS, DOC_REL)); + doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText()); + await settle(); + api.proposalController.renderAll(doc); + const rendered = api.proposalController.getRendered(DOC_REL); + assert.strictEqual(rendered.length, 1, "proposal survived reload"); + assert.strictEqual(rendered[0].pending, true, "still decidable"); + const moved = doc.getText().indexOf(anchor); + assert.strictEqual(rendered[0].range.start, moved, "re-anchored after the move"); + }); + + test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => { + let doc = await openDoc(); + const api = await getApi(); + const id = api.proposalController.getRendered(DOC_REL)[0].id; + const mangled = doc.getText().replace("A stable closing paragraph.", "A reworded closing paragraph."); + doc = await externalWriteAndReload(vscode.Uri.file(path.join(WS, DOC_REL)), mangled); + await settle(); + api.proposalController.renderAll(doc); + assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale"); + const before = doc.getText(); + const ok = await api.proposalController.acceptById(DOC_REL, id); + assert.strictEqual(ok, false, "accept refused (INV-11)"); + assert.strictEqual(doc.getText(), before, "document untouched"); + assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)"); + // discard the husk so later suites see a clean sidecar + assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true); + }); + + test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => { + const doc = await openDoc(); + const api = await getApi(); + const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4"); + const id2 = await proposeViaCommand(doc, "PROPOSED-BY-CLAUDE replacement sentence.", "A REPLACED-AGAIN sentence.", "turn-p5"); + await settle(); + assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2); + // decide the SECOND first, then the first — order independence + assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true); + await settle(); + assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true); + await settle(); + assert.ok(doc.getText().includes("An ACCEPTED opening paragraph.")); + assert.ok(doc.getText().includes("A REPLACED-AGAIN sentence.")); + assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0); + }); +}); +``` + +- [ ] **Step 3: Check the no-workspace pass** — read `test/e2e/suite/` for the #8 regression test; if it enumerates contributed commands, add `cowriting.acceptProposal`, `cowriting.rejectProposal`, `cowriting.proposeAgentEdit` to its list (the stubs were added in Task 5). + +- [ ] **Step 4: Run the full E2E suite** + +Run: `npm run pretest:e2e && npm run test:e2e` +Expected: all suites pass (F2 threads, F3 attribution, F4 proposals, no-workspace pass). + +- [ ] **Step 5: Commit** + +```bash +git add test/e2e/fixtures/workspace/docs/proposal.md test/e2e/suite/proposals.test.ts +git commit -m "F4 SLICE-5: host E2E — propose/accept/reject/persist/re-anchor/stale/coexist, no LLM in CI (#12)" +``` + +--- + +### Task 8: Docs + full gate + +**Files:** +- Modify: `docs/MANUAL-SMOKE-F3.md` (the live flow now ends in a proposal) +- Create: `docs/MANUAL-SMOKE-F4.md` +- Modify: `README.md` (features) +- Modify: `sandbox/playground.md` (step 2 of the play loop now reviews a proposal) + +- [ ] **Step 1: Update the smoke + README docs.** In `docs/MANUAL-SMOKE-F3.md`, add a note at the top: "As of F4 (#12), the edit-selection turn ends in a **proposal** — the F3 direct-apply behavior now happens on ✓ Accept. The full propose→accept smoke is `MANUAL-SMOKE-F4.md`." Create `docs/MANUAL-SMOKE-F4.md`: + +```markdown +# Manual smoke — F4 propose/accept (live turn) + +Pre-req: Claude Code installed + signed in (the `claude-code` provider rides +that login — the extension holds no credentials, INV-8). Run on a real machine, +not CI. + +1. `npm run build`, then F5 (the EDH opens the committed `sandbox/` playground). +2. Open `playground.md`, select a sentence → right-click → **Ask Claude to Edit + Selection** → give an instruction. +3. ✅ A **proposal** appears: the selection gets an amber tint and a "Claude + proposes" comment thread shows a `diff` of current → proposed. **The document + text is unchanged** (INV-10). +4. Click **✓ Accept Proposal** in the thread title. ✅ The replacement lands and + renders Claude-tinted (the F3 attribution substrate); the proposal disappears. +5. Repeat with another selection and click **✗ Reject Proposal**. ✅ The document + is untouched; the proposal disappears. +6. Propose again, then save and reload the window. ✅ The pending proposal is + restored at its re-resolved range. +7. Failure path: edit the proposed-on text, then try Accept. ✅ Refused with a + warning ("target text changed"); undo your edit → Accept works. +``` + +In `README.md`, find the features list (the F2/F3 entries) and add: "**F4 — propose/accept**: Claude's edits arrive as pending diff proposals (amber tint + comment thread); ✓ accept applies via the attribution seam, ✗ reject discards — the document never changes without your say-so." In `sandbox/playground.md`, update step 2 ("Ask Claude to Edit Selection — the replacement lands Claude-tinted") to: "Ask Claude to Edit Selection — Claude **proposes** a diff (amber tint + thread); ✓ accept and it lands Claude-tinted." + +- [ ] **Step 2: Run the FULL verification gate** + +Run: `npm run typecheck && npm test && npm run build && npm run pretest:e2e && npm run test:e2e` +Expected: everything green. (Live smoke is a manual, documented step — perform it once per `docs/MANUAL-SMOKE-F4.md` before calling #12 done.) + +- [ ] **Step 3: Commit** + +```bash +git add docs/MANUAL-SMOKE-F3.md docs/MANUAL-SMOKE-F4.md README.md sandbox/playground.md +git commit -m "F4: document propose/accept — manual smoke, README, playground loop (#12)" +``` + +--- + +## Self-review notes + +- **Spec coverage:** §6.3 model → Task 1/3; store prune → Task 2; §6.4 controller contracts (propose/acceptById/rejectById/getRendered/getStaleCount, hidden command) → Tasks 4/5; §6.5 PUC-1..5 → Tasks 4/6/7; §5 UX (amber deco, second comments controller, ✓/✗ title actions, status-bar stale count) → Tasks 4/5; INV-10..13 asserted in E2E (Task 7); §6.8 testing strategy → Tasks 1–3 (unit) + 7 (host E2E) + 8 (manual smoke); §7.3 done-bar → Task 8 gate + smoke. +- **Type consistency:** `Proposal`/`Fingerprint`/`Provenance` from `src/model.ts`; `resolve`/`shift`/`buildFingerprint`/`OffsetRange` from `src/anchorer.ts`; `propose(document, fp, replacement, author, opts)` used identically in Tasks 4/5/6; `acceptById`/`rejectById`/`getRendered`/`getStaleCount` used identically in Tasks 4/7. +- **Known presentation caveat (per spec §6.7/§7.4):** the fenced-diff body is whole-range -/+ (no LCS) and may render oddly if the target text itself contains ``` fences — presentation-only; truth is `fingerprint.text` → `replacement`. From 652f8619d2f2695a2763838b7bf74fbf78354f36 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:02:08 -0700 Subject: [PATCH 2/9] =?UTF-8?q?F4=20SLICE-1:=20typed=20proposals[]=20on=20?= =?UTF-8?q?shared=20anchors/Provenance=20(spec=20=C2=A76.3,=20INV-13)=20(#?= =?UTF-8?q?12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/model.ts | 30 ++++++++++++++++++++++--- test/proposalModel.test.ts | 46 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 test/proposalModel.test.ts diff --git a/src/model.ts b/src/model.ts index 968578b..4ac79f1 100644 --- a/src/model.ts +++ b/src/model.ts @@ -61,6 +61,22 @@ export interface AttributionRecord { turnId?: string; } +/** F4 (spec §6.3, INV-13): a PENDING machine edit — state, not history. */ +export interface Proposal { + id: string; + /** shared anchors map; fingerprint.text IS the exact target text (INV-11). */ + anchorId: string; + /** the full proposed text for the anchored range. */ + replacement: string; + author: Provenance; + /** ISO-8601. */ + createdAt: string; + /** groups N proposals born of one live turn. */ + turnId?: string; + /** what the human asked for (review context). */ + instruction?: string; +} + export interface Artifact { schemaVersion: number; /** repo-relative path; the sidecar key. */ @@ -70,8 +86,8 @@ export interface Artifact { threads: Thread[]; /** F3 fills this (spec §6.3, INV-4): typed attribution records anchored via anchors[]. */ attributions: AttributionRecord[]; - /** F4 extension point — reuses anchors[] + provenance; not in F2. */ - proposals: unknown[]; + /** F4 (spec §6.3, INV-13): pending proposals on shared anchors[] + Provenance. */ + proposals: Proposal[]; } export function emptyArtifact(docPath: string): Artifact { @@ -139,7 +155,15 @@ export function serializeArtifact(a: Artifact): string { updatedAt: at.updatedAt, ...(at.turnId !== undefined ? { turnId: at.turnId } : {}), })), - proposals: a.proposals, + proposals: a.proposals.map((p) => ({ + id: p.id, + anchorId: p.anchorId, + replacement: p.replacement, + author: serializeProvenance(p.author), + createdAt: p.createdAt, + ...(p.turnId !== undefined ? { turnId: p.turnId } : {}), + ...(p.instruction !== undefined ? { instruction: p.instruction } : {}), + })), }; return JSON.stringify(canonical, null, 2) + "\n"; } diff --git a/test/proposalModel.test.ts b/test/proposalModel.test.ts new file mode 100644 index 0000000..ad8d3a5 --- /dev/null +++ b/test/proposalModel.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + emptyArtifact, + serializeArtifact, + type Artifact, + type Proposal, +} from "../src/model"; + +const agent = { + kind: "agent" as const, + id: "claude", + agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "s1" }, +}; + +describe("Proposal model (spec §6.3, INV-13)", () => { + it("serializes proposals with stable field order and survives a round-trip", () => { + const a = emptyArtifact("docs/x.md"); + a.anchors["a_1"] = { + fingerprint: { text: "old text", before: "", after: "", lineHint: 0 }, + }; + const p: Proposal = { + id: "pr_1", + anchorId: "a_1", + replacement: "new text", + author: agent, + createdAt: "2026-06-10T00:00:00.000Z", + turnId: "turn-1", + instruction: "tighten", + }; + a.proposals.push(p); + const reloaded = JSON.parse(serializeArtifact(a)) as Artifact; + expect(reloaded.proposals).toHaveLength(1); + expect(reloaded.proposals[0]).toEqual(p); + // optional fields are OMITTED (not null) when absent + const bare: Proposal = { + id: "pr_2", anchorId: "a_1", replacement: "r", author: agent, + createdAt: "2026-06-10T00:00:00.000Z", + }; + a.proposals.push(bare); + const again = JSON.parse(serializeArtifact(a)) as Artifact; + expect("turnId" in again.proposals[1]).toBe(false); + expect("instruction" in again.proposals[1]).toBe(false); + // serialization is byte-stable (INV-2) + expect(serializeArtifact(again)).toBe(serializeArtifact(a)); + }); +}); From d5beb619aa7ea2b8ec1e8331c25dba7aab259cb3 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:02:48 -0700 Subject: [PATCH 3/9] F4 SLICE-1: anchor prune counts proposal anchorIds (closes store.ts F4 TODO) (#12) Co-Authored-By: Claude Opus 4.8 --- src/store.ts | 5 +++-- test/proposalModel.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/store.ts b/src/store.ts index 53c9a21..e8c467a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -61,8 +61,8 @@ export class CoauthorStore { * Read-modify-write for sidecar co-ownership (spec F3 §6.2): each controller * mutates only its own section against a FRESH load, so ThreadController and * AttributionController never clobber each other. After the mutation, anchors - * referenced by neither threads nor attributions are pruned (proposals are - * still empty in F3 — revisit in F4). `mutate` MUST be synchronous: the + * referenced by no thread, attribution, OR proposal are pruned (threads, + * attributions, and proposals all keep their anchors — F4). `mutate` MUST be synchronous: the * read-modify-write (and the self-write mark) completes within this call. */ update(docPath: string, mutate: (artifact: Artifact) => void): Artifact { @@ -71,6 +71,7 @@ export class CoauthorStore { const referenced = new Set([ ...artifact.threads.map((t) => t.anchorId), ...artifact.attributions.map((a) => a.anchorId), + ...artifact.proposals.map((p) => p.anchorId), ]); for (const id of Object.keys(artifact.anchors)) { if (!referenced.has(id)) delete artifact.anchors[id]; diff --git a/test/proposalModel.test.ts b/test/proposalModel.test.ts index ad8d3a5..43eedbc 100644 --- a/test/proposalModel.test.ts +++ b/test/proposalModel.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { emptyArtifact, @@ -5,6 +8,7 @@ import { type Artifact, type Proposal, } from "../src/model"; +import { CoauthorStore } from "../src/store"; const agent = { kind: "agent" as const, @@ -44,3 +48,28 @@ describe("Proposal model (spec §6.3, INV-13)", () => { expect(serializeArtifact(again)).toBe(serializeArtifact(a)); }); }); + +describe("CoauthorStore prune with proposals (spec §6.3)", () => { + it("retains anchors referenced only by proposals; prunes them after removal", () => { + const dir = mkdtempSync(join(tmpdir(), "cowriting-prune-")); + try { + const store = new CoauthorStore(dir); + store.update("docs/x.md", (a) => { + a.anchors["a_p"] = { + fingerprint: { text: "t", before: "", after: "", lineHint: 0 }, + }; + a.proposals.push({ + id: "pr_1", anchorId: "a_p", replacement: "r", author: agent, + createdAt: "2026-06-10T00:00:00.000Z", + }); + }); + expect(store.load("docs/x.md")!.anchors["a_p"]).toBeDefined(); + store.update("docs/x.md", (a) => { + a.proposals = a.proposals.filter((p) => p.id !== "pr_1"); + }); + expect(store.load("docs/x.md")!.anchors["a_p"]).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); From f4746849463100c0f561e72f47e52a2e46ae1dab Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:03:20 -0700 Subject: [PATCH 4/9] =?UTF-8?q?F4=20SLICE-1:=20proposalModel=20helpers=20?= =?UTF-8?q?=E2=80=94=20add/remove=20+=20fenced-diff=20body=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/proposalModel.ts | 47 ++++++++++++++++++++++++++++++++++++++ test/proposalModel.test.ts | 32 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/proposalModel.ts diff --git a/src/proposalModel.ts b/src/proposalModel.ts new file mode 100644 index 0000000..2f699cd --- /dev/null +++ b/src/proposalModel.ts @@ -0,0 +1,47 @@ +/** + * proposalModel — pure helpers over the F4 `proposals[]` section (spec §6.3, + * INV-13: pending-only). Mirrors threadModel.ts: vscode-free mutations against + * an Artifact, used inside CoauthorStore.update() by ProposalController. + * The diff body is PRESENTATION ONLY — the truth is fingerprint.text → + * replacement (INV-11); a whole-range -/+ rendering, not an LCS diff (§6.7). + */ +import { newId, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model"; + +export function addProposal( + artifact: Artifact, + fp: Fingerprint, + replacement: string, + author: Provenance, + opts?: { turnId?: string; instruction?: string }, +): { proposalId: string; anchorId: string } { + const anchorId = newId("a"); + const proposalId = newId("pr"); + artifact.anchors[anchorId] = { fingerprint: fp }; + artifact.proposals.push({ + id: proposalId, + anchorId, + replacement, + author, + createdAt: new Date().toISOString(), + ...(opts?.turnId !== undefined ? { turnId: opts.turnId } : {}), + ...(opts?.instruction !== undefined ? { instruction: opts.instruction } : {}), + }); + return { proposalId, anchorId }; +} + +/** Remove a pending proposal (accept and reject both end here — INV-13). */ +export function removeProposal(artifact: Artifact, proposalId: string): boolean { + const before = artifact.proposals.length; + artifact.proposals = artifact.proposals.filter((p) => p.id !== proposalId); + return artifact.proposals.length < before; +} + +/** Markdown comment body: instruction header + fenced whole-range diff. */ +export function proposalBody(targetText: string, p: Proposal): string { + const header = p.instruction ? `**Claude proposes** — _${p.instruction}_` : "**Claude proposes**"; + const diffLines = [ + ...targetText.split("\n").map((l) => `- ${l}`), + ...p.replacement.split("\n").map((l) => `+ ${l}`), + ].join("\n"); + return `${header}\n\n\`\`\`diff\n${diffLines}\n\`\`\`\n\n✓ Accept applies this replacement · ✗ Reject discards it`; +} diff --git a/test/proposalModel.test.ts b/test/proposalModel.test.ts index 43eedbc..9fce315 100644 --- a/test/proposalModel.test.ts +++ b/test/proposalModel.test.ts @@ -9,6 +9,7 @@ import { type Proposal, } from "../src/model"; import { CoauthorStore } from "../src/store"; +import { addProposal, proposalBody, removeProposal } from "../src/proposalModel"; const agent = { kind: "agent" as const, @@ -73,3 +74,34 @@ describe("CoauthorStore prune with proposals (spec §6.3)", () => { } }); }); + +describe("proposalModel helpers (spec §6.4)", () => { + const fp = { text: "old line one\nold line two", before: "", after: "", lineHint: 3 }; + + it("addProposal creates the anchor + pending record; removeProposal deletes by id", () => { + const a = emptyArtifact("docs/x.md"); + const { proposalId, anchorId } = addProposal(a, fp, "new line", agent, { + turnId: "turn-9", instruction: "shorten", + }); + expect(a.anchors[anchorId].fingerprint).toEqual(fp); + expect(a.proposals).toHaveLength(1); + expect(a.proposals[0].id).toBe(proposalId); + expect(a.proposals[0].replacement).toBe("new line"); + expect(a.proposals[0].turnId).toBe("turn-9"); + expect(a.proposals[0].instruction).toBe("shorten"); + expect(removeProposal(a, proposalId)).toBe(true); + expect(a.proposals).toHaveLength(0); + expect(removeProposal(a, proposalId)).toBe(false); + }); + + it("proposalBody renders instruction + a fenced unified diff of old → new", () => { + const a = emptyArtifact("docs/x.md"); + addProposal(a, fp, "new only line", agent, { instruction: "merge the lines" }); + const body = proposalBody(fp.text, a.proposals[0]); + expect(body).toContain("**Claude proposes** — _merge the lines_"); + expect(body).toContain("```diff"); + expect(body).toContain("- old line one"); + expect(body).toContain("- old line two"); + expect(body).toContain("+ new only line"); + }); +}); From 4efd34ffdab66d9425899dcac41c9204770f1e18 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:04:19 -0700 Subject: [PATCH 5/9] =?UTF-8?q?F4=20SLICE-2/3:=20ProposalController=20?= =?UTF-8?q?=E2=80=94=20propose=20ingress,=20resolve-or-flag,=20diff=20thre?= =?UTF-8?q?ads,=20accept/reject=20via=20the=20seam=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/proposalController.ts | 332 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 src/proposalController.ts diff --git a/src/proposalController.ts b/src/proposalController.ts new file mode 100644 index 0000000..9295080 --- /dev/null +++ b/src/proposalController.ts @@ -0,0 +1,332 @@ +/** + * ProposalController — the editor-facing layer for F4 (spec + * coauthoring-propose-accept §6.2). Owns the proposal lifecycle: the propose + * ingress (INV-10: NEVER mutates the document), persistence at propose time, + * resolve-or-flag on load/external change (INV-11 — a proposal's anchor is + * immutable for its life: no save-time re-fingerprint, unlike threads), + * rendering (second Comments controller + amber pending-range decoration), + * and the human-only accept/reject gestures (INV-12). Accept drives the seam + * (AttributionController.applyAgentEdit, INV-9) so accepted text lands + * Claude-attributed with zero new attribution code. + */ +import * as vscode from "vscode"; +import { CoauthorStore } from "./store"; +import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model"; +import { resolve, shift, type OffsetRange } from "./anchorer"; +import { addProposal, proposalBody, removeProposal } from "./proposalModel"; +import type { AttributionController } from "./attributionController"; + +/** Test-facing snapshot of what is currently rendered for a document. */ +export interface RenderedProposal { + id: string; + /** true → resolves exactly, decidable; false → stale/orphaned (INV-11). */ + pending: boolean; + turnId?: string; + range: { start: number; end: number }; +} + +interface DocState { + docPath: string; + uri: vscode.Uri; + artifact: Artifact; + vsThreads: Map; + /** proposal id -> live offset range (within-session optimization, INV-3). */ + live: Map; + /** proposal ids whose anchor did not resolve at last render (stale/orphaned). */ + unresolved: Set; +} + +const PENDING_DECO: vscode.DecorationRenderOptions = { + backgroundColor: "rgba(245, 158, 11, 0.18)", + overviewRulerColor: "rgba(245, 158, 11, 0.8)", + overviewRulerLane: vscode.OverviewRulerLane.Right, +}; + +export class ProposalController implements vscode.Disposable { + private readonly controller: vscode.CommentController; + private readonly disposables: vscode.Disposable[] = []; + private readonly docs = new Map(); // keyed by docPath + private readonly pendingType = vscode.window.createTextEditorDecorationType(PENDING_DECO); + private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 89); + + constructor( + private readonly store: CoauthorStore, + private readonly attribution: AttributionController, + private readonly rootDir: string, + ) { + // No commentingRangeProvider: humans never open proposal threads by hand — + // proposals are born of machine turns only (INV-12 keeps decisions human). + this.controller = vscode.comments.createCommentController("cowriting.proposals", "Claude Proposals"); + this.disposables.push(this.controller, this.pendingType, this.statusItem); + this.disposables.push( + vscode.commands.registerCommand("cowriting.acceptProposal", (t: vscode.CommentThread) => this.acceptThread(t)), + vscode.commands.registerCommand("cowriting.rejectProposal", (t: vscode.CommentThread) => this.rejectThread(t)), + vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), + ); + } + + private isTracked(document: vscode.TextDocument): boolean { + return document.uri.scheme === "file" && document.uri.fsPath.startsWith(this.rootDir); + } + private docPathOf(uri: vscode.Uri): string { + return vscode.workspace.asRelativePath(uri, false); + } + private ensureState(document: vscode.TextDocument): DocState { + const docPath = this.docPathOf(document.uri); + let state = this.docs.get(docPath); + if (!state) { + state = { + docPath, + uri: document.uri, + artifact: this.store.load(docPath) ?? emptyArtifact(docPath), + vsThreads: new Map(), + live: new Map(), + unresolved: new Set(), + }; + this.docs.set(docPath, state); + } + return state; + } + + // ---- PUC-1: the propose ingress (INV-10) ----------------------------------------- + + /** + * Record + render a pending proposal. The fingerprint is built by the CALLER + * at gesture time (editSelection captures it BEFORE the turn, so mid-turn + * edits can't skew the anchor). Never touches the document. + */ + async propose( + document: vscode.TextDocument, + fp: Fingerprint, + replacement: string, + author: Provenance, + opts?: { turnId?: string; instruction?: string }, + ): Promise { + if (!this.isTracked(document)) return undefined; + const docPath = this.docPathOf(document.uri); + let proposalId: string | undefined; + this.store.update(docPath, (a) => { + proposalId = addProposal(a, fp, replacement, author, opts).proposalId; + }); + this.renderAll(document); + return proposalId; + } + + // ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ---------------------------------- + + /** Accept by proposal id (test-facing twin of the thread-menu gesture). */ + async acceptById(docPath: string, proposalId: string): Promise { + const hit = this.byId(docPath, proposalId); + return hit ? this.accept(hit.state, hit.proposal) : false; + } + /** Reject by proposal id (test-facing twin of the thread-menu gesture). */ + rejectById(docPath: string, proposalId: string): boolean { + const hit = this.byId(docPath, proposalId); + if (!hit) return false; + this.reject(hit.state, hit.proposal); + return true; + } + + private async acceptThread(vsThread: vscode.CommentThread): Promise { + const hit = this.byThread(vsThread); + if (hit) await this.accept(hit.state, hit.proposal); + } + private rejectThread(vsThread: vscode.CommentThread): void { + const hit = this.byThread(vsThread); + if (hit) this.reject(hit.state, hit.proposal); + } + + private async accept(state: DocState, proposal: Proposal): Promise { + const document = this.openDoc(state); + if (!document) return false; + // INV-11: the fingerprint-guard — exact re-resolve at decision time. + const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; + const resolved = fp ? resolve(document.getText(), fp) : "orphaned"; + if (resolved === "orphaned") { + void vscode.window.showWarningMessage( + "Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).", + ); + 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, + }); + if (!ok) { + void vscode.window.showWarningMessage( + "Cowriting: the editor rejected the accept — the proposal is still pending.", + ); + return false; + } + this.store.update(state.docPath, (a) => removeProposal(a, proposal.id)); + this.renderAll(document); + return true; + } + + private reject(state: DocState, proposal: Proposal): void { + this.store.update(state.docPath, (a) => removeProposal(a, proposal.id)); + const document = this.openDoc(state); + if (document) this.renderAll(document); + } + + // ---- PUC-4: load / external change / resolve-or-flag -------------------------------- + + /** Load + (re)render every pending proposal at its resolved anchor (or flagged). */ + renderAll(document: vscode.TextDocument): void { + if (!this.isTracked(document)) return; + const docPath = this.docPathOf(document.uri); + const state = this.ensureState(document); + state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); + for (const vsThread of state.vsThreads.values()) vsThread.dispose(); + state.vsThreads.clear(); + state.live.clear(); + state.unresolved.clear(); + const text = document.getText(); + for (const proposal of state.artifact.proposals) { + const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; + const resolved = fp ? resolve(text, fp) : "orphaned"; + if (resolved === "orphaned") { + const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0; + const off = document.offsetAt(new vscode.Position(line, 0)); + this.renderProposal(document, state, proposal, { start: off, end: off }, false); + } else { + this.renderProposal(document, state, proposal, resolved, true); + } + } + this.renderDecorations(document, state); + this.renderStatus(state); + } + + /** Shared-watcher entry point (extension.ts): a sidecar changed externally. */ + handleExternalSidecarChange(uri: vscode.Uri): void { + for (const state of this.docs.values()) { + if (this.store.sidecarPath(state.docPath) === uri.fsPath) { + const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); + if (doc) this.renderAll(doc); + } + } + } + + private onDidChange(e: vscode.TextDocumentChangeEvent): void { + const state = this.docs.get(this.docPathOf(e.document.uri)); + if (!state || state.live.size === 0) return; + for (const change of e.contentChanges) { + const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length }; + for (const [id, range] of state.live) { + const next = shift(range, edit); + state.live.set(id, next); + const vsThread = state.vsThreads.get(id); + if (vsThread && !state.unresolved.has(id)) { + vsThread.range = new vscode.Range(e.document.positionAt(next.start), e.document.positionAt(next.end)); + } + } + } + // Live shift keeps the UI following; staleness is judged at decision time + // (accept re-resolves, INV-11) and at the next renderAll. + this.renderDecorations(e.document, state); + } + + // ---- rendering ----------------------------------------------------------------------- + + private renderProposal( + document: vscode.TextDocument, + state: DocState, + proposal: Proposal, + offsets: OffsetRange, + pending: boolean, + ): void { + const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; + const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end)); + const vsThread = this.controller.createCommentThread(document.uri, range, [ + { + body: new vscode.MarkdownString(proposalBody(fp?.text ?? "", proposal)), + mode: vscode.CommentMode.Preview, + author: { name: proposal.author.id }, + }, + ]); + vsThread.label = pending + ? "Pending proposal" + : "⚠ Stale proposal (target text changed or missing) — accept disabled"; + vsThread.contextValue = pending ? "pending" : "unresolved"; + vsThread.collapsibleState = pending + ? vscode.CommentThreadCollapsibleState.Expanded + : vscode.CommentThreadCollapsibleState.Collapsed; + state.vsThreads.set(proposal.id, vsThread); + state.live.set(proposal.id, offsets); + if (!pending) state.unresolved.add(proposal.id); + } + + private renderDecorations(document: vscode.TextDocument, state: DocState): void { + const ranges: vscode.Range[] = []; + for (const [id, off] of state.live) { + if (state.unresolved.has(id)) continue; + ranges.push(new vscode.Range(document.positionAt(off.start), document.positionAt(off.end))); + } + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document === document) editor.setDecorations(this.pendingType, ranges); + } + } + + private renderStatus(state: DocState): void { + const n = state.unresolved.size; + if (n === 0) { + this.statusItem.hide(); + return; + } + this.statusItem.text = `$(warning) ${n} stale proposal${n === 1 ? "" : "s"}`; + this.statusItem.tooltip = + "Cowriting: proposals whose target text changed or is missing — undo to restore, or reject"; + this.statusItem.show(); + } + + // ---- lookups ---------------------------------------------------------------------------- + + private openDoc(state: DocState): vscode.TextDocument | undefined { + return vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); + } + private byThread(vsThread: vscode.CommentThread): { state: DocState; proposal: Proposal } | undefined { + for (const state of this.docs.values()) { + for (const [id, t] of state.vsThreads) { + if (t === vsThread) { + const proposal = state.artifact.proposals.find((p) => p.id === id); + if (proposal) return { state, proposal }; + } + } + } + return undefined; + } + private byId(docPath: string, proposalId: string): { state: DocState; proposal: Proposal } | undefined { + const state = this.docs.get(docPath); + const proposal = state?.artifact.proposals.find((p) => p.id === proposalId); + return state && proposal ? { state, proposal } : undefined; + } + + // ---- test-facing surface ------------------------------------------------------------------ + + getRendered(docPath: string): RenderedProposal[] { + const state = this.docs.get(docPath); + if (!state) return []; + const out: RenderedProposal[] = []; + for (const [id] of state.vsThreads) { + const p = state.artifact.proposals.find((x) => x.id === id)!; + const off = state.live.get(id)!; + out.push({ + id, + pending: !state.unresolved.has(id), + turnId: p.turnId, + range: { start: off.start, end: off.end }, + }); + } + return out; + } + getStaleCount(docPath: string): number { + return this.docs.get(docPath)?.unresolved.size ?? 0; + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + } +} From 4688ac8bbdfda706de7082579200ed15131901ff Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:05:16 -0700 Subject: [PATCH 6/9] =?UTF-8?q?F4=20SLICE-3:=20wire=20ProposalController?= =?UTF-8?q?=20=E2=80=94=20contributes,=20watcher=20fan-out,=20propose=20co?= =?UTF-8?q?mmand,=20stubs,=20API=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- package.json | 20 ++++++++++++++++++-- src/extension.ts | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e94f3a4..be51ccf 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,17 @@ { "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" }, { "command": "cowriting.toggleAttribution", "title": "Toggle Attribution", "category": "Cowriting" }, { "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" }, - { "command": "cowriting.editSelection", "title": "Ask Claude to Edit Selection", "category": "Cowriting" } + { "command": "cowriting.editSelection", "title": "Ask Claude to Edit Selection", "category": "Cowriting" }, + { "command": "cowriting.acceptProposal", "title": "✓ Accept Proposal", "category": "Cowriting" }, + { "command": "cowriting.rejectProposal", "title": "✗ Reject Proposal", "category": "Cowriting" }, + { "command": "cowriting.proposeAgentEdit", "title": "Propose Agent Edit (internal seam)", "category": "Cowriting" } ], "menus": { "commandPalette": [ - { "command": "cowriting.applyAgentEdit", "when": "false" } + { "command": "cowriting.applyAgentEdit", "when": "false" }, + { "command": "cowriting.proposeAgentEdit", "when": "false" }, + { "command": "cowriting.acceptProposal", "when": "false" }, + { "command": "cowriting.rejectProposal", "when": "false" } ], "editor/context": [ { @@ -61,6 +67,16 @@ "command": "cowriting.reopenThread", "group": "inline", "when": "commentController == cowriting.threads && commentThread =~ /^resolved$/" + }, + { + "command": "cowriting.acceptProposal", + "group": "inline@1", + "when": "commentController == cowriting.proposals && commentThread =~ /^pending$/" + }, + { + "command": "cowriting.rejectProposal", + "group": "inline@2", + "when": "commentController == cowriting.proposals" } ] } diff --git a/src/extension.ts b/src/extension.ts index f99eaf1..58c4d7c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,12 +3,15 @@ import { fetchSdkSummary } from "./cline"; import { CoauthorStore } from "./store"; import { ThreadController } from "./threadController"; import { AttributionController } from "./attributionController"; +import { ProposalController } from "./proposalController"; +import { buildFingerprint } from "./anchorer"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; export interface CowritingApi { threadController: ThreadController; attributionController: AttributionController; + proposalController: ProposalController; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { @@ -55,6 +58,9 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef "cowriting.editSelection", "cowriting.toggleAttribution", "cowriting.applyAgentEdit", + "cowriting.acceptProposal", + "cowriting.rejectProposal", + "cowriting.proposeAgentEdit", ]) { context.subscriptions.push(vscode.commands.registerCommand(command, stub)); } @@ -68,6 +74,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const attributionController = new AttributionController(store, root); context.subscriptions.push(attributionController); + // --- F4: propose/accept (Feature #12) --- + const proposalController = new ProposalController(store, attributionController, root); + context.subscriptions.push(proposalController); + // One SHARED sidecar watcher for both controllers; self-writes are // suppressed centrally in the store (the sidecar is co-owned). const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); @@ -75,6 +85,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef if (store.consumeSelfWrite(uri.fsPath)) return; threadController.handleExternalSidecarChange(uri); attributionController.handleExternalSidecarChange(uri); + proposalController.handleExternalSidecarChange(uri); }; watcher.onDidChange(onSidecar); watcher.onDidCreate(onSidecar); @@ -102,6 +113,31 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ), ); + // The propose ingress as a command (spec §6.4): records a pending proposal, + // NEVER touches the document (INV-10) — for the host E2E harness. + context.subscriptions.push( + vscode.commands.registerCommand( + "cowriting.proposeAgentEdit", + (args: { + uri: string; start: number; end: number; newText: string; + model?: string; sessionId?: string; turnId?: string; instruction?: string; + }) => { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === args.uri); + if (!doc) return Promise.resolve(undefined); + const fp = buildFingerprint(doc.getText(), { start: args.start, end: args.end }); + const provenance = { + kind: "agent" as const, + id: "claude", + agent: { sdk: "@cline/sdk", model: args.model ?? "sonnet", sessionId: args.sessionId ?? "" }, + }; + return proposalController.propose(doc, fp, args.newText, provenance, { + turnId: args.turnId, + instruction: args.instruction, + }); + }, + ), + ); + // F3 SLICE-5: the live turn (PUC-2) — selection + instruction → one // claude-code SDK turn (liveTurn.ts, INV-8) → the applyAgentEdit seam (INV-9). context.subscriptions.push( @@ -172,12 +208,13 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) { threadController.renderAll(doc); attributionController.loadAll(doc); + proposalController.renderAll(doc); } }; vscode.workspace.textDocuments.forEach(renderIfOpen); context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen)); - return { threadController, attributionController }; + return { threadController, attributionController, proposalController }; } export function deactivate(): void { From 452c071efb3b548c5274a95a4b6a7e337ca47b7c Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:05:44 -0700 Subject: [PATCH 7/9] =?UTF-8?q?F4=20SLICE-4:=20editSelection=20is=20propos?= =?UTF-8?q?e-by-default=20=E2=80=94=20the=20turn=20ends=20in=20a=20proposa?= =?UTF-8?q?l,=20the=20seam=20fires=20on=20accept=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/extension.ts | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 58c4d7c..a42d6b0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -138,8 +138,9 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ), ); - // F3 SLICE-5: the live turn (PUC-2) — selection + instruction → one - // claude-code SDK turn (liveTurn.ts, INV-8) → the applyAgentEdit seam (INV-9). + // F3 SLICE-5 → F4 SLICE-4: the live turn — selection + instruction → one + // claude-code SDK turn (liveTurn.ts, INV-8) → a PENDING PROPOSAL (F4, + // INV-10); the applyAgentEdit seam (INV-9) now fires only on accept. context.subscriptions.push( vscode.commands.registerCommand("cowriting.editSelection", async () => { const editor = vscode.window.activeTextEditor; @@ -163,8 +164,13 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef } const document = editor.document; const selection = editor.selection; - const expectedVersion = document.version; const selectedText = document.getText(selection); + // Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits + // can't skew it — the proposal renders wherever the target re-resolves. + const fp = buildFingerprint(document.getText(), { + start: document.offsetAt(selection.start), + end: document.offsetAt(selection.end), + }); const turnId = `turn-${Date.now().toString(36)}`; try { await vscode.window.withProgress( @@ -174,24 +180,32 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const turn = await runEditTurn(instruction, selectedText); if (turn.replacement === "") { void vscode.window.showWarningMessage( - "Cowriting: Claude returned an empty replacement — nothing was applied.", + "Cowriting: Claude returned an empty replacement — nothing was proposed.", ); return; } - const ok = await attributionController.applyAgentEdit( + if (turn.replacement === selectedText) { + void vscode.window.showInformationMessage( + "Cowriting: Claude proposed no change to the selection.", + ); + return; + } + // F4 (INV-10): the turn ends in a PROPOSAL, not a buffer mutation. + // The seam now fires only on accept (ProposalController, INV-9). + const id = await proposalController.propose( document, - new vscode.Range(selection.start, selection.end), + fp, turn.replacement, { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId }, }, - { expectedVersion, turnId }, + { turnId, instruction }, ); - if (!ok) { - void vscode.window.showWarningMessage( - "Cowriting: the edit could not be applied (the document changed during the turn, or the editor rejected the edit) — nothing was changed.", + if (id) { + void vscode.window.showInformationMessage( + "Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).", ); } }, From 8fdea97d361f35b75f9cbee3554312e7f230bd86 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:12:15 -0700 Subject: [PATCH 8/9] F4 SLICE-5: host E2E (propose/accept/reject/persist/stale/coexist) + seam fix: event-level net-effect matching survives host word-diff splitting (INV-9) (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accept E2E exposed a latent F3 limitation: VS Code word-diffs one applied WorkspaceEdit into several minimal hunks when old/new share interior tokens, so the registry's per-hunk exact match missed and seam edits fell through as fragmented human spans. PendingEditRegistry.match is replaced by matchEvent (all hunks inside the registered full range + equal net delta — one applyEdit is one change event). Plan AMENDMENT 1. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-10-f4-propose-accept.md | 15 ++ src/attributionController.ts | 45 +++-- src/pendingEdits.ts | 31 ++-- test/e2e/fixtures/workspace/docs/proposal.md | 9 + .../suite-no-workspace/noWorkspace.test.ts | 3 + test/e2e/suite/proposals.test.ts | 163 ++++++++++++++++++ test/pendingEdits.test.ts | 47 +++-- 7 files changed, 278 insertions(+), 35 deletions(-) create mode 100644 test/e2e/fixtures/workspace/docs/proposal.md create mode 100644 test/e2e/suite/proposals.test.ts diff --git a/docs/superpowers/plans/2026-06-10-f4-propose-accept.md b/docs/superpowers/plans/2026-06-10-f4-propose-accept.md index 1a008a5..af10294 100644 --- a/docs/superpowers/plans/2026-06-10-f4-propose-accept.md +++ b/docs/superpowers/plans/2026-06-10-f4-propose-accept.md @@ -1153,6 +1153,21 @@ git commit -m "F4: document propose/accept — manual smoke, README, playground --- +## Amendments (during execution) + +- **AMENDMENT 1 (Task 7):** the F4 accept E2E exposed a latent F3 seam + limitation — the host word-diffs one applied WorkspaceEdit into SEVERAL + minimal hunks when old/new share interior tokens (F4's realistic + replacements do; F3's test strings never did), so the registry's per-hunk + exact match missed and the edit fell through as fragmented HUMAN spans. + Fix: `PendingEditRegistry.match` (per-change equality) is replaced by + `matchEvent` (event-level net-effect match: every hunk inside the + registered full pre-edit range + equal net length delta — sound because one + applyEdit is one change event), and `AttributionController.onDidChange` + matches the event once, applying the full intended replacement as ONE + algebra edit on a hit. Unit tests rewritten accordingly + (`test/pendingEdits.test.ts`); INV-9's miss-warning path unchanged. + ## Self-review notes - **Spec coverage:** §6.3 model → Task 1/3; store prune → Task 2; §6.4 controller contracts (propose/acceptById/rejectById/getRendered/getStaleCount, hidden command) → Tasks 4/5; §6.5 PUC-1..5 → Tasks 4/6/7; §5 UX (amber deco, second comments controller, ✓/✗ title actions, status-bar stale count) → Tasks 4/5; INV-10..13 asserted in E2E (Task 7); §6.8 testing strategy → Tasks 1–3 (unit) + 7 (host E2E) + 8 (manual smoke); §7.3 done-bar → Task 8 gate + smoke. diff --git a/src/attributionController.ts b/src/attributionController.ts index 245d5e2..dd74390 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -146,24 +146,39 @@ export class AttributionController implements vscode.Disposable { return; } const s = this.state(docPath); - // Sort descending by offset so earlier changes don't invalidate later offsets - // (VS Code's order is undocumented; defensive sort is the safe guarantee). - for (const change of [...e.contentChanges].sort((a, b) => b.rangeOffset - a.rangeOffset)) { - const edit = { - start: change.rangeOffset, - end: change.rangeOffset + change.rangeLength, - newLength: change.text.length, - }; - const hit = this.pending.match(docPath, { start: edit.start, end: edit.end, text: change.text }); - const author = hit ? hit.provenance : this.currentAuthor(); - // On a seam hit, attribute the agent's FULL intended replacement (the - // registered edit is diff-minimized for transport only): same delta, - // wider span — the agent owns every char it asserted (INV-9). - s.spans = applyChange(s.spans, hit?.full ?? edit, author, { + // One applyEdit = one change event, but the host may deliver a seam edit + // as SEVERAL minimal hunks (word-level diffing). Match the EVENT's net + // effect against the registry; on a hit the agent owns its FULL intended + // replacement (INV-9) — apply it as ONE algebra edit, not per hunk. + const hit = this.pending.matchEvent( + docPath, + e.contentChanges.map((c) => ({ + start: c.rangeOffset, + end: c.rangeOffset + c.rangeLength, + newLength: c.text.length, + })), + ); + if (hit) { + const full = hit.full ?? { start: hit.start, end: hit.end, newLength: hit.newText.length }; + s.spans = applyChange(s.spans, full, hit.provenance, { newId: () => newId("at"), now: () => new Date().toISOString(), - turnId: hit?.turnId, + turnId: hit.turnId, }); + } else { + // Sort descending by offset so earlier changes don't invalidate later offsets + // (VS Code's order is undocumented; defensive sort is the safe guarantee). + for (const change of [...e.contentChanges].sort((a, b) => b.rangeOffset - a.rangeOffset)) { + const edit = { + start: change.rangeOffset, + end: change.rangeOffset + change.rangeLength, + newLength: change.text.length, + }; + s.spans = applyChange(s.spans, edit, this.currentAuthor(), { + newId: () => newId("at"), + now: () => new Date().toISOString(), + }); + } } if (s.spans.length > 0) s.hadAttributions = true; this.render(e.document); diff --git a/src/pendingEdits.ts b/src/pendingEdits.ts index 5760eb8..a646786 100644 --- a/src/pendingEdits.ts +++ b/src/pendingEdits.ts @@ -67,17 +67,28 @@ export class PendingEditRegistry { } /** - * Find-and-consume the registration exactly matching a change event - * (same doc, same replaced range, same inserted text). Null → human edit. + * Find-and-consume the registration matching a change EVENT's net effect. + * The host may deliver one applied WorkspaceEdit as SEVERAL minimal hunks + * (word-level diffing — observed on the F4 accept path), so per-hunk + * equality misses real seam edits and they fall through as "human typing". + * A pending edit matches an event when EVERY hunk lies inside its full + * pre-edit range and the event's net length delta equals the edit's + * (one applyEdit = one change event, so a seam event is never mixed with + * human hunks). Null → human edit. */ - match(docPath: string, change: { start: number; end: number; text: string }): PendingEdit | null { - const i = this.pending.findIndex( - (p) => - p.docPath === docPath && - p.start === change.start && - p.end === change.end && - p.newText === change.text, - ); + matchEvent( + docPath: string, + changes: ReadonlyArray<{ start: number; end: number; newLength: number }>, + ): PendingEdit | null { + if (changes.length === 0) return null; + const i = this.pending.findIndex((p) => { + if (p.docPath !== docPath) return false; + const full = p.full ?? { start: p.start, end: p.end, newLength: p.newText.length }; + const delta = full.newLength - (full.end - full.start); + const eventDelta = changes.reduce((d, c) => d + (c.newLength - (c.end - c.start)), 0); + if (delta !== eventDelta) return false; + return changes.every((c) => c.start >= full.start && c.end <= full.end); + }); if (i === -1) return null; const [hit] = this.pending.splice(i, 1); return hit; diff --git a/test/e2e/fixtures/workspace/docs/proposal.md b/test/e2e/fixtures/workspace/docs/proposal.md new file mode 100644 index 0000000..7a6cb36 --- /dev/null +++ b/test/e2e/fixtures/workspace/docs/proposal.md @@ -0,0 +1,9 @@ +# Proposal fixture + +A stable opening paragraph. + +The propose target sentence lives here. + +A second target for coexistence checks. + +A stable closing paragraph. diff --git a/test/e2e/suite-no-workspace/noWorkspace.test.ts b/test/e2e/suite-no-workspace/noWorkspace.test.ts index 96b9d07..b3748f0 100644 --- a/test/e2e/suite-no-workspace/noWorkspace.test.ts +++ b/test/e2e/suite-no-workspace/noWorkspace.test.ts @@ -25,6 +25,9 @@ suite("no-workspace activation (#8)", () => { "cowriting.editSelection", "cowriting.toggleAttribution", "cowriting.applyAgentEdit", + "cowriting.acceptProposal", + "cowriting.rejectProposal", + "cowriting.proposeAgentEdit", ]) { assert.ok(all.includes(command), `${command} is registered`); } diff --git a/test/e2e/suite/proposals.test.ts b/test/e2e/suite/proposals.test.ts new file mode 100644 index 0000000..0ba7c9c --- /dev/null +++ b/test/e2e/suite/proposals.test.ts @@ -0,0 +1,163 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; +import type { Artifact } from "../../../src/model"; + +const WS = process.env.E2E_WORKSPACE!; +const DOC_REL = "docs/proposal.md"; + +function sidecarPath(): string { + return path.join(WS, ".threads", "docs", "proposal.md.json"); +} +function readSidecar(): Artifact { + return JSON.parse(fs.readFileSync(sidecarPath(), "utf8")) as Artifact; +} +async function openDoc(): Promise { + const uri = vscode.Uri.file(path.join(WS, DOC_REL)); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + return doc; +} +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.proposalController, "extension exports proposalController"); + return api; +} +async function proposeViaCommand( + doc: vscode.TextDocument, + target: string, + newText: string, + turnId: string, +): Promise { + const start = doc.getText().indexOf(target); + assert.ok(start >= 0, `fixture contains "${target}"`); + const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { + uri: doc.uri.toString(), + start, + end: start + target.length, + newText, + model: "sonnet", + sessionId: "e2e-prop", + turnId, + instruction: "e2e instruction", + }); + assert.ok(id, "propose returns the proposal id"); + return id!; +} +async function externalWriteAndReload(uri: vscode.Uri, content: string): Promise { + fs.writeFileSync(uri.fsPath, content, "utf8"); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("workbench.action.files.revert"); + return doc; +} +const settle = () => new Promise((r) => setTimeout(r, 300)); + +// Tests are ORDER-DEPENDENT (F2/F3 pattern): later tests consume earlier state. +// This suite OWNS docs/proposal.md; threads owns docs/sample.md, attribution +// owns docs/attrib.md — fixtures stay disjoint. +suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => { + const TARGET = "The propose target sentence lives here."; + const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence."; + + test("propose records + renders a pending proposal and does NOT touch the document (INV-10)", async () => { + const doc = await openDoc(); + const api = await getApi(); + const before = doc.getText(); + await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1"); + await settle(); + assert.strictEqual(doc.getText(), before, "document text unchanged (INV-10)"); + const rendered = api.proposalController.getRendered(DOC_REL); + assert.strictEqual(rendered.length, 1); + assert.strictEqual(rendered[0].pending, true); + assert.strictEqual(rendered[0].turnId, "turn-p1"); + const art = readSidecar(); + assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time"); + assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, TARGET); + }); + + test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => { + const doc = await openDoc(); + const api = await getApi(); + const id = api.proposalController.getRendered(DOC_REL)[0].id; + const ok = await api.proposalController.acceptById(DOC_REL, id); + assert.strictEqual(ok, true, "accept applies"); + await settle(); + assert.ok(doc.getText().includes(REPLACEMENT), "replacement landed"); + assert.ok(!doc.getText().includes(TARGET), "target gone"); + const spans = api.attributionController.getSpans(DOC_REL); + const agent = spans.find((s) => s.turnId === "turn-p1"); + assert.ok(agent, `accepted text is Claude-attributed with the proposal's turnId — got spans: ${JSON.stringify(spans)}`); + assert.strictEqual(agent!.authorKind, "agent"); + assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0, "proposal gone (INV-13)"); + assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar"); + }); + + test("reject leaves the document untouched and removes the proposal (PUC-3)", async () => { + const doc = await openDoc(); + const api = await getApi(); + const before = doc.getText(); + const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2"); + await settle(); + assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true); + await settle(); + assert.strictEqual(doc.getText(), before, "document untouched"); + assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0); + assert.strictEqual(readSidecar().proposals.length, 0); + }); + + test("a pending proposal persists, survives reload, and re-anchors after an external move (PUC-4)", async () => { + let doc = await openDoc(); + const api = await getApi(); + const anchor = "A stable closing paragraph."; + await proposeViaCommand(doc, anchor, "A PROPOSED closing paragraph.", "turn-p3"); + await settle(); + const uri = vscode.Uri.file(path.join(WS, DOC_REL)); + doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText()); + await settle(); + api.proposalController.renderAll(doc); + const rendered = api.proposalController.getRendered(DOC_REL); + assert.strictEqual(rendered.length, 1, "proposal survived reload"); + assert.strictEqual(rendered[0].pending, true, "still decidable"); + const moved = doc.getText().indexOf(anchor); + assert.strictEqual(rendered[0].range.start, moved, "re-anchored after the move"); + }); + + test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => { + let doc = await openDoc(); + const api = await getApi(); + const id = api.proposalController.getRendered(DOC_REL)[0].id; + const mangled = doc.getText().replace("A stable closing paragraph.", "A reworded closing paragraph."); + doc = await externalWriteAndReload(vscode.Uri.file(path.join(WS, DOC_REL)), mangled); + await settle(); + api.proposalController.renderAll(doc); + assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale"); + const before = doc.getText(); + const ok = await api.proposalController.acceptById(DOC_REL, id); + assert.strictEqual(ok, false, "accept refused (INV-11)"); + assert.strictEqual(doc.getText(), before, "document untouched"); + assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)"); + // discard the husk so later tests see a clean sidecar + assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true); + }); + + test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => { + const doc = await openDoc(); + const api = await getApi(); + const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4"); + const id2 = await proposeViaCommand(doc, "PROPOSED-BY-CLAUDE replacement sentence.", "A REPLACED-AGAIN sentence.", "turn-p5"); + await settle(); + assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2); + // decide the SECOND first, then the first — order independence + assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true); + await settle(); + assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true); + await settle(); + assert.ok(doc.getText().includes("An ACCEPTED opening paragraph.")); + assert.ok(doc.getText().includes("A REPLACED-AGAIN sentence.")); + assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0); + }); +}); diff --git a/test/pendingEdits.test.ts b/test/pendingEdits.test.ts index 81d241d..2df9372 100644 --- a/test/pendingEdits.test.ts +++ b/test/pendingEdits.test.ts @@ -8,26 +8,53 @@ const AGENT: Provenance = { }; describe("PendingEditRegistry (INV-9)", () => { - it("matches and consumes an exact registration", () => { + it("matches and consumes a single-hunk event equal to the registration", () => { const reg = new PendingEditRegistry(); - reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1" }); - const hit = reg.match("d.md", { start: 3, end: 7, text: "new" }); + reg.register({ + docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1", + full: { start: 3, end: 7, newLength: 3 }, + }); + const hit = reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }]); expect(hit?.turnId).toBe("t1"); - expect(reg.match("d.md", { start: 3, end: 7, text: "new" })).toBeNull(); + expect(reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }])).toBeNull(); }); - it("does not match a different doc, range, or text (human typing stays human)", () => { + it("matches a HOST-SPLIT event: several hunks inside the full range with the same net delta", () => { + // The host word-diffs one applied replace into multiple minimal hunks + // (observed on F4 accept) — per-hunk equality misses; net-effect matches. + const reg = new PendingEditRegistry(); + reg.register({ + docPath: "d.md", start: 10, end: 50, newText: "x".repeat(45), provenance: AGENT, turnId: "t2", + full: { start: 10, end: 50, newLength: 45 }, + }); + const hit = reg.matchEvent("d.md", [ + { start: 12, end: 20, newLength: 9 }, + { start: 25, end: 30, newLength: 6 }, + { start: 40, end: 48, newLength: 11 }, + ]); + expect(hit?.turnId).toBe("t2"); + }); + it("does not match a different doc, hunks outside the full range, or a different delta", () => { + const reg = new PendingEditRegistry(); + reg.register({ + docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, + full: { start: 3, end: 7, newLength: 3 }, + }); + expect(reg.matchEvent("other.md", [{ start: 3, end: 7, newLength: 3 }])).toBeNull(); + expect(reg.matchEvent("d.md", [{ start: 3, end: 8, newLength: 4 }])).toBeNull(); + expect(reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 4 }])).toBeNull(); + expect(reg.matchEvent("d.md", [])).toBeNull(); + }); + it("falls back to the minimized range when no full extent was registered", () => { const reg = new PendingEditRegistry(); reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT }); - expect(reg.match("other.md", { start: 3, end: 7, text: "new" })).toBeNull(); - expect(reg.match("d.md", { start: 3, end: 8, text: "new" })).toBeNull(); - expect(reg.match("d.md", { start: 3, end: 7, text: "neww" })).toBeNull(); + expect(reg.matchEvent("d.md", [{ start: 3, end: 7, newLength: 3 }])).not.toBeNull(); }); it("unregister removes a failed application", () => { const reg = new PendingEditRegistry(); const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT }; reg.register(p); reg.unregister(p); - expect(reg.match("d.md", { start: 0, end: 0, text: "x" })).toBeNull(); + expect(reg.matchEvent("d.md", [{ start: 0, end: 0, newLength: 1 }])).toBeNull(); }); it("unregister reports whether the registration was still pending", () => { const reg = new PendingEditRegistry(); @@ -35,7 +62,7 @@ describe("PendingEditRegistry (INV-9)", () => { reg.register(p); expect(reg.unregister(p)).toBe(true); reg.register(p); - reg.match("d.md", { start: 0, end: 0, text: "x" }); + reg.matchEvent("d.md", [{ start: 0, end: 0, newLength: 1 }]); expect(reg.unregister(p)).toBe(false); }); }); From 169abf06c8bb1caec253de67551f2601a2f38b62 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:14:13 -0700 Subject: [PATCH 9/9] =?UTF-8?q?F4:=20document=20propose/accept=20=E2=80=94?= =?UTF-8?q?=20manual=20smoke,=20README,=20playground=20loop=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- README.md | 28 ++++++++++++++++++++++++++-- docs/MANUAL-SMOKE-F3.md | 11 ++++++++--- docs/MANUAL-SMOKE-F4.md | 20 ++++++++++++++++++++ sandbox/playground.md | 6 ++++-- 4 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 docs/MANUAL-SMOKE-F4.md diff --git a/README.md b/README.md index e6bb5da..99c5805 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ catalog (a pure, key-free SDK call) in a notification and the "Cowriting (Cline SDK)" output channel. Features shipped so far: F2 region-anchored threads (Feature #4), F3 live -human/Claude attribution (Feature #6). +human/Claude attribution (Feature #6), F4 propose/accept diff flow +(Feature #12). ## Architecture @@ -67,13 +68,36 @@ Attribution" output channel) rather than silently moved or discarded. - **`Cowriting: Ask Claude to Edit Selection`** — select text → enter an instruction → a live `@cline/sdk` turn runs on the built-in `claude-code` provider (rides your local Claude Code Pro/Max login; the extension stores no - credentials). The replacement lands in the buffer as a Claude-attributed span. + credentials). As of F4 the turn ends in a **proposal** (see below); accepted + text lands as a Claude-attributed span. - **`Cowriting: Toggle Attribution`** — show/hide attribution decorations. - `cowriting.applyAgentEdit` _(palette-hidden)_ — the single machine-edit ingress seam. Tests drive this directly so CI requires no LLM. Design: `vscode-cowriting-plugin-content/specs/coauthoring-attribution.md`. +## F4 — Propose/accept diff flow (Feature #12) + +Claude's edits arrive as **pending proposals** — propose-by-default, the +document never changes without your say-so. A proposal renders two ways at +once: an **amber tint** on the target range, and a "Claude proposes" comment +thread showing a fenced `diff` of current → proposed text with two actions: + +- **✓ Accept Proposal** — applies the replacement through the `applyAgentEdit` + seam, so it lands Claude-attributed (F3) — and the proposal disappears. +- **✗ Reject Proposal** — the document is untouched; the proposal disappears. + +Pending proposals persist git-natively in the same sidecar (`proposals[]`, +sharing the F2/F3 `anchors` fingerprints), survive reload, and re-anchor as +surrounding text changes. If the **target text itself** changes, the proposal +goes **stale** (status-bar count, accept disabled — never applied by guess); +undo the change and it becomes decidable again. `cowriting.proposeAgentEdit` +_(palette-hidden)_ is the programmatic propose ingress E2E drives — no LLM in +CI. + +Design: `vscode-cowriting-plugin-content/specs/coauthoring-propose-accept.md`. +Live smoke: [`docs/MANUAL-SMOKE-F4.md`](docs/MANUAL-SMOKE-F4.md). + ## Develop - `npm run watch` — rebuild on change. diff --git a/docs/MANUAL-SMOKE-F3.md b/docs/MANUAL-SMOKE-F3.md index fe82cfe..89df7cb 100644 --- a/docs/MANUAL-SMOKE-F3.md +++ b/docs/MANUAL-SMOKE-F3.md @@ -1,5 +1,10 @@ # F3 manual smoke — live `claude-code` turn (spec §6.8) +> **As of F4 (#12)** the edit-selection turn ends in a **proposal** — the +> direct-apply behavior in §2 step 4 now happens on **✓ Accept Proposal**. The +> full propose→accept smoke is [`MANUAL-SMOKE-F4.md`](./MANUAL-SMOKE-F4.md); +> §1's scripted smoke is unchanged (it drives the SDK turn only, no editor). + The live turn is deliberately NOT in CI (unit + host E2E drive the seam). It gets this documented smoke, run once per machine that has Claude Code installed and signed in (Pro/Max). The extension itself holds no credentials @@ -37,9 +42,9 @@ the model id, a non-empty `sessionId`, and exits 0. with a clear error (`runEditTurn` throws on any non-`completed` run status); in-editor, the command shows an error notification and NO edit is applied. -- Buffer edited mid-turn: start an edit-selection turn, type elsewhere in the - document before it completes → warning notification "document changed", no - partial application (stale `expectedVersion`, spec §6.9). +- Buffer edited mid-turn: superseded by F4 — the turn ends in a proposal, so a + mid-turn edit can't race an application; the proposal simply renders where + its target re-resolves (or stale if the target itself changed — F4 INV-11). ## Smoke log diff --git a/docs/MANUAL-SMOKE-F4.md b/docs/MANUAL-SMOKE-F4.md new file mode 100644 index 0000000..5956fa1 --- /dev/null +++ b/docs/MANUAL-SMOKE-F4.md @@ -0,0 +1,20 @@ +# Manual smoke — F4 propose/accept (live turn) + +Pre-req: Claude Code installed + signed in (the `claude-code` provider rides +that login — the extension holds no credentials, INV-8). Run on a real machine, +not CI. + +1. `npm run build`, then F5 (the EDH opens the committed `sandbox/` playground). +2. Open `playground.md`, select a sentence → right-click → **Ask Claude to Edit + Selection** → give an instruction. +3. ✅ A **proposal** appears: the selection gets an amber tint and a "Claude + proposes" comment thread shows a `diff` of current → proposed. **The document + text is unchanged** (INV-10). +4. Click **✓ Accept Proposal** in the thread title. ✅ The replacement lands and + renders Claude-tinted (the F3 attribution substrate); the proposal disappears. +5. Repeat with another selection and click **✗ Reject Proposal**. ✅ The document + is untouched; the proposal disappears. +6. Propose again, then save and reload the window. ✅ The pending proposal is + restored at its re-resolved range. +7. Failure path: edit the proposed-on text, then try Accept. ✅ Refused with a + warning ("target text changed"); undo your edit → Accept works. diff --git a/sandbox/playground.md b/sandbox/playground.md index 234efdf..23024fd 100644 --- a/sandbox/playground.md +++ b/sandbox/playground.md @@ -8,8 +8,10 @@ open in your dev window (#8). Play here: 1. Type a sentence below — it renders with the human left-border as you type. -2. Highlight it → right-click → **Ask Claude to Edit Selection** — the - replacement lands Claude-tinted. +2. Highlight it → right-click → **Ask Claude to Edit Selection** — Claude + **proposes** a diff (amber tint + a "Claude proposes" thread); click + **✓ Accept Proposal** and it lands Claude-tinted, or **✗ Reject Proposal** + to discard. 3. Edit inside the tinted span — it splits character-precisely. 4. Save, then peek at `sandbox/.threads/playground.md.json` — the git-native attribution record. Reopen the file: spans re-resolve.