1176 lines
48 KiB
Markdown
1176 lines
48 KiB
Markdown
# 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 1–3 (unit) + 7 (host E2E) + 8 (manual smoke); §7.3 done-bar → Task 8 gate + smoke.
|
||
- **Type consistency:** `Proposal`/`Fingerprint`/`Provenance` from `src/model.ts`; `resolve`/`shift`/`buildFingerprint`/`OffsetRange` from `src/anchorer.ts`; `propose(document, fp, replacement, author, opts)` used identically in Tasks 4/5/6; `acceptById`/`rejectById`/`getRendered`/`getStaleCount` used identically in Tasks 4/7.
|
||
- **Known presentation caveat (per spec §6.7/§7.4):** the fenced-diff body is whole-range -/+ (no LCS) and may render oddly if the target text itself contains ``` fences — presentation-only; truth is `fingerprint.text` → `replacement`.
|