Files
vscode-cowriting-plugin/docs/superpowers/plans/2026-06-10-f4-propose-accept.md
T

47 KiB
Raw Blame History

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

// 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:

/** 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:

  /** F4 (spec §6.3, INV-13): pending proposals on shared anchors[] + Provenance. */
  proposals: Proposal[];

In serializeArtifact, replace proposals: a.proposals, with:

    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
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:

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:

    const referenced = new Set<string>([
      ...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
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:

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
/**
 * 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
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
/**
 * 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<string, vscode.CommentThread>;
  /** proposal id -> live offset range (within-session optimization, INV-3). */
  live: Map<string, OffsetRange>;
  /** proposal ids whose anchor did not resolve at last render (stale/orphaned). */
  unresolved: Set<string>;
}

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<string, DocState>(); // 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<string | undefined> {
    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<boolean> {
    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<void> {
    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<boolean> {
    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
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):

      { "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:

        { "command": "cowriting.proposeAgentEdit", "when": "false" },
        { "command": "cowriting.acceptProposal", "when": "false" },
        { "command": "cowriting.rejectProposal", "when": "false" }

In contributes.menus["comments/commentThread/title"], add:

        {
          "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:

import { ProposalController } from "./proposalController";
import { buildFingerprint } from "./anchorer";

Extend the API type:

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):

      "cowriting.acceptProposal",
      "cowriting.rejectProposal",
      "cowriting.proposeAgentEdit",

After the AttributionController construction, add:

  // --- F4: propose/accept (Feature #12) ---
  const proposalController = new ProposalController(store, attributionController, root);
  context.subscriptions.push(proposalController);

In the shared-watcher onSidecar fan-out, add:

    proposalController.handleExternalSidecarChange(uri);

After the cowriting.applyAgentEdit command registration, add the propose ingress command (the E2E entry point — INV-10):

  // 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:

      proposalController.renderAll(doc);

And return the extended API:

  return { threadController, attributionController, proposalController };
  • Step 3: Typecheck + build + unit suite

Run: npm run typecheck && npm run build && npm test Expected: clean.

  • Step 4: Commit
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:

      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
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)

# 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
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<vscode.TextDocument> {
  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<CowritingApi> {
  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<string> {
  const start = doc.getText().indexOf(target);
  assert.ok(start >= 0, `fixture contains "${target}"`);
  const id = await vscode.commands.executeCommand<string>("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<vscode.TextDocument> {
  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
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:

# 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
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 13 (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.textreplacement.