From debd09fb97a301eef32c1eb2168c64889a131497 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:00:42 -0700 Subject: [PATCH] =?UTF-8?q?F4:=20add=20implementation=20plan=20(just-in-ti?= =?UTF-8?q?me=20from=20coauthoring-propose-accept=20spec=20=C2=A77.2)=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 --- .../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`.