Files
vscode-cowriting-plugin/docs/superpowers/plans/2026-06-10-f4-propose-accept.md
T
Ben Stull 8fdea97d36 F4 SLICE-5: host E2E (propose/accept/reject/persist/stale/coexist) + seam fix: event-level net-effect matching survives host word-diff splitting (INV-9) (#12)
The accept E2E exposed a latent F3 limitation: VS Code word-diffs one
applied WorkspaceEdit into several minimal hunks when old/new share
interior tokens, so the registry's per-hunk exact match missed and seam
edits fell through as fragmented human spans. PendingEditRegistry.match
is replaced by matchEvent (all hunks inside the registered full range +
equal net delta — one applyEdit is one change event). Plan AMENDMENT 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:12:15 -07:00

1176 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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**
```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<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**
```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<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**
```bash
git add test/e2e/fixtures/workspace/docs/proposal.md test/e2e/suite/proposals.test.ts
git commit -m "F4 SLICE-5: host E2E — propose/accept/reject/persist/re-anchor/stale/coexist, no LLM in CI (#12)"
```
---
### Task 8: Docs + full gate
**Files:**
- Modify: `docs/MANUAL-SMOKE-F3.md` (the live flow now ends in a proposal)
- Create: `docs/MANUAL-SMOKE-F4.md`
- Modify: `README.md` (features)
- Modify: `sandbox/playground.md` (step 2 of the play loop now reviews a proposal)
- [ ] **Step 1: Update the smoke + README docs.** In `docs/MANUAL-SMOKE-F3.md`, add a note at the top: "As of F4 (#12), the edit-selection turn ends in a **proposal** — the F3 direct-apply behavior now happens on ✓ Accept. The full propose→accept smoke is `MANUAL-SMOKE-F4.md`." Create `docs/MANUAL-SMOKE-F4.md`:
```markdown
# Manual smoke — F4 propose/accept (live turn)
Pre-req: Claude Code installed + signed in (the `claude-code` provider rides
that login — the extension holds no credentials, INV-8). Run on a real machine,
not CI.
1. `npm run build`, then F5 (the EDH opens the committed `sandbox/` playground).
2. Open `playground.md`, select a sentence → right-click → **Ask Claude to Edit
Selection** → give an instruction.
3. ✅ A **proposal** appears: the selection gets an amber tint and a "Claude
proposes" comment thread shows a `diff` of current → proposed. **The document
text is unchanged** (INV-10).
4. Click **✓ Accept Proposal** in the thread title. ✅ The replacement lands and
renders Claude-tinted (the F3 attribution substrate); the proposal disappears.
5. Repeat with another selection and click **✗ Reject Proposal**. ✅ The document
is untouched; the proposal disappears.
6. Propose again, then save and reload the window. ✅ The pending proposal is
restored at its re-resolved range.
7. Failure path: edit the proposed-on text, then try Accept. ✅ Refused with a
warning ("target text changed"); undo your edit → Accept works.
```
In `README.md`, find the features list (the F2/F3 entries) and add: "**F4 — propose/accept**: Claude's edits arrive as pending diff proposals (amber tint + comment thread); ✓ accept applies via the attribution seam, ✗ reject discards — the document never changes without your say-so." In `sandbox/playground.md`, update step 2 ("Ask Claude to Edit Selection — the replacement lands Claude-tinted") to: "Ask Claude to Edit Selection — Claude **proposes** a diff (amber tint + thread); ✓ accept and it lands Claude-tinted."
- [ ] **Step 2: Run the FULL verification gate**
Run: `npm run typecheck && npm test && npm run build && npm run pretest:e2e && npm run test:e2e`
Expected: everything green. (Live smoke is a manual, documented step — perform it once per `docs/MANUAL-SMOKE-F4.md` before calling #12 done.)
- [ ] **Step 3: Commit**
```bash
git add docs/MANUAL-SMOKE-F3.md docs/MANUAL-SMOKE-F4.md README.md sandbox/playground.md
git commit -m "F4: document propose/accept — manual smoke, README, playground loop (#12)"
```
---
## Amendments (during execution)
- **AMENDMENT 1 (Task 7):** the F4 accept E2E exposed a latent F3 seam
limitation — the host word-diffs one applied WorkspaceEdit into SEVERAL
minimal hunks when old/new share interior tokens (F4's realistic
replacements do; F3's test strings never did), so the registry's per-hunk
exact match missed and the edit fell through as fragmented HUMAN spans.
Fix: `PendingEditRegistry.match` (per-change equality) is replaced by
`matchEvent` (event-level net-effect match: every hunk inside the
registered full pre-edit range + equal net length delta — sound because one
applyEdit is one change event), and `AttributionController.onDidChange`
matches the event once, applying the full intended replacement as ONE
algebra edit on a hit. Unit tests rewritten accordingly
(`test/pendingEdits.test.ts`); INV-9's miss-warning path unchanged.
## Self-review notes
- **Spec coverage:** §6.3 model → Task 1/3; store prune → Task 2; §6.4 controller contracts (propose/acceptById/rejectById/getRendered/getStaleCount, hidden command) → Tasks 4/5; §6.5 PUC-1..5 → Tasks 4/6/7; §5 UX (amber deco, second comments controller, ✓/✗ title actions, status-bar stale count) → Tasks 4/5; INV-10..13 asserted in E2E (Task 7); §6.8 testing strategy → Tasks 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.text` → `replacement`.