# 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)" ``` --- ## 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. - **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`.