From ef61b141b68a1d027010f88076d2856bfe142844 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Wed, 1 Jul 2026 22:40:40 -0700 Subject: [PATCH] =?UTF-8?q?plan(0064):=20native-surfaces=20migration=20?= =?UTF-8?q?=E2=80=94=20ONE=20plan=20from=20coauthoring-native-surfaces=20v?= =?UTF-8?q?0.2.1=20(D17/=C2=A76.10/=C2=A77.1=20rung=203+4),=209=20tasks,?= =?UTF-8?q?=20sunsets=20gated=20on=20green=20replacements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-07-01-native-surfaces-migration.md | 1252 +++++++++++++++++ 1 file changed, 1252 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-native-surfaces-migration.md diff --git a/docs/superpowers/plans/2026-07-01-native-surfaces-migration.md b/docs/superpowers/plans/2026-07-01-native-surfaces-migration.md new file mode 100644 index 0000000..df150d9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-native-surfaces-migration.md @@ -0,0 +1,1252 @@ +# Native Surfaces Migration 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:** Evolve the shipped cowriting extension in place onto VS Code's native review surfaces — opt-in coediting, git-HEAD/snapshot baseline with QuickDiff + native diff, comments-first asking with a machine reply/offer loop, and annotations in the built-in Markdown preview — sunsetting the two bespoke webviews. + +**Architecture:** Per `specs/coauthoring-native-surfaces.md` **v0.2.1** (graduated; D17 evolve-in-place, §6.10). The 21 pure cores (attribution, anchoring, diff/hunk engine, sidecar, turn progress) are retained untouched; thin controllers are rewired: a `CoeditingRegistry` gates every surface (INV-10), the baseline follows the file's home (git `HEAD` / snapshot, INV-7), a `QuickDiffProvider` + `cowriting-baseline:` content provider drive gutter bars and the native diff, the Comments API becomes the ask surface (D19/D10), and a `markdown-it` contribution annotates the built-in preview (D3/D21). Each bespoke surface sunsets only after its native replacement is green (§7.1 rung 4 order). + +**Tech Stack:** TypeScript, VS Code stable APIs only (SCM/QuickDiff, `vscode.diff`, Comments, CodeLens, decorations, `markdown.markdownItPlugins`), built-in Git extension API v1, `@cline/sdk` (ESM, dynamic import, never bundled), esbuild, vitest (unit), `@vscode/test-electron` (host E2E — **no Playwright**, §6.8). + +## Global Constraints + +- **INV-3 stable-API boundary:** only the surfaces named above; any new custom rendering needs a recorded justification. +- **INV-5:** nothing settles until Keep; Reject restores the retained original **exactly**. +- **INV-6:** new pure logic goes in vscode-free modules with vitest suites; controllers stay thin. +- **INV-7:** baseline = git `HEAD` where the file is tracked (advances on commit only), else a snapshot (set on enter, re-pinned by "Mark Changes as Reviewed"); baseline is never committed. **The shipped machine-landing baseline advance (INV-18) is retired** — kept machine text stays a visible change until commit/pin. +- **INV-10:** every surface gates on the CoeditingRegistry; non-entered documents show nothing. +- **Spike-verified seams (spec §6.4, v0.2.1):** re-assign `commentingRangeProvider` whenever the coediting set changes; focus the comment input via `workbench.action.addComment`; the thread-contextValue menu `when` key is **`commentThread`**; key HEAD re-reads off `state.HEAD.commit`; nested repos may need `openRepository()`. +- **Command copy (spec §5):** "✦ Coedit this Document with Claude", "Stop editing with Claude", "Review Changes", "Ask Claude", "Toggle Annotations", "Mark Changes as Reviewed" (snapshot mode only, D14). Status bar: `✦ Coediting · N changes`. +- **`@cline/sdk`** stays `external` in `esbuild.mjs` and is loaded via dynamic `import()`; spawned with `CLAUDE_CODE_AUTO_CONNECT_IDE=0` + `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1` (#59). +- **Sunset discipline (§6.10):** `trackChangesPreview.ts` and `editInstructionInput.ts` are deleted only in their sunset task, after the native replacement's tests are green. +- Verification loop for every task: `npm run typecheck && npm run test`, and where the task touches controllers `npm run pretest:e2e && npm run test:e2e`. +- Commits cite the spec section and carry `Co-Authored-By: Claude Fable 5 `. + +## File Structure (end state) + +``` +src/ + coeditingRegistry.ts NEW — the INV-10 opt-in gate (thin, persisted set + context keys) + gitBaseline.ts NEW — built-in Git-extension adapter (HEAD text, commit watch) + scmSurface.ts NEW — SourceControl "Cowriting": QuickDiff, cowriting-baseline: provider, + Review Changes (native diff), status bar, Mark Changes as Reviewed + editFlow.ts NEW — runEditAndPropose + editDocument/editSelection turn plumbing + (extracted from trackChangesPreview.ts before its sunset) + previewAnnotations.ts NEW — pure markdown-it plugin: sentinel-injected authorship + change marks + diffViewController.ts MOD — baseline modes (head|snapshot), reasons (entered|pinned|head), + enter-time capture, machine-landing advance retired + threadController.ts MOD — registry gating + provider re-assign; Ask Claude; comment→turn→ + reply+offer loop (D19/D10/D8) + editorProposalController.ts MOD — registry gating; ✓ Keep / ✗ Reject + Keep all (N) lens copy + attributionController.ts MOD — registry gating (typing tracked only while coediting) + proposalController.ts MOD — registry gating; finalize no longer advances the baseline + extension.ts MOD — construction order, gating wires, extendMarkdownIt export + trackChangesPreview.ts DEL (Task 8) + editInstructionInput.ts DEL (Task 6) +media/preview-annotations.css NEW — preview stylesheet (markdown.previewStyles) +test/ — new vitest suites per new pure module +test/e2e/suite/ — coediting.test.ts, baselineRouter.test.ts, commentLoop.test.ts, + previewAnnotations.test.ts (+ existing suites updated) +``` + +--- + +### Task 1: CoeditingRegistry — the INV-10 opt-in gate + +**Files:** +- Create: `src/coeditingRegistry.ts` +- Test: `test/coeditingRegistry.test.ts` (pure set logic), `test/e2e/suite/coediting.test.ts` (context keys + enter/exit; extended in later tasks) +- Modify: `src/extension.ts` (construct + export), `package.json` (commands + menus) + +**Interfaces:** +- Consumes: `vscode.Memento` (`context.workspaceState`), `setContext`. +- Produces (later tasks rely on these exact names): + ```typescript + export class CoeditingRegistry implements vscode.Disposable { + constructor(state: vscode.Memento); + enter(uri: vscode.Uri): void; + exit(uri: vscode.Uri): void; + isCoediting(uri: vscode.Uri): boolean; + list(): string[]; // uri strings + readonly onDidChange: vscode.Event<{ uri: string; coediting: boolean }>; + /** Re-derives cowriting.isCoediting / cowriting.baselineMode for the active editor. */ + syncContext(editor: vscode.TextEditor | undefined): void; + dispose(): void; + } + ``` + Context keys: `cowriting.isCoediting` (boolean), commands `cowriting.coeditDocument`, `cowriting.stopCoediting`. + +- [ ] **Step 1: Write the failing unit test** + +```typescript +// test/coeditingRegistry.test.ts +import { describe, expect, it, vi } from "vitest"; + +// The pure set logic lives in a vscode-free helper the class wraps, so unit-test the class +// with a Memento stub (vitest runs vscode-free; the module imports vscode only for types + +// EventEmitter — mock it). +vi.mock("vscode", () => ({ + EventEmitter: class { + private handlers: Array<(e: unknown) => void> = []; + event = (h: (e: unknown) => void) => { this.handlers.push(h); return { dispose() {} }; }; + fire(e: unknown) { for (const h of this.handlers) h(e); } + dispose() {} + }, + Uri: { parse: (s: string) => ({ toString: () => s }) }, + commands: { executeCommand: vi.fn() }, + window: { activeTextEditor: undefined }, +})); +import { CoeditingRegistry } from "../src/coeditingRegistry"; + +function memento(): { store: Map } & { get: any; update: any } { + const store = new Map(); + return { + store, + get: (k: string, dflt?: unknown) => (store.has(k) ? store.get(k) : dflt), + update: (k: string, v: unknown) => { store.set(k, v); return Promise.resolve(); }, + } as any; +} + +describe("CoeditingRegistry", () => { + it("enter/exit flips membership and fires onDidChange", () => { + const reg = new CoeditingRegistry(memento() as any); + const events: Array<{ uri: string; coediting: boolean }> = []; + reg.onDidChange((e) => events.push(e as any)); + const uri = { toString: () => "file:///a.md" } as any; + expect(reg.isCoediting(uri)).toBe(false); + reg.enter(uri); + expect(reg.isCoediting(uri)).toBe(true); + reg.exit(uri); + expect(reg.isCoediting(uri)).toBe(false); + expect(events).toEqual([ + { uri: "file:///a.md", coediting: true }, + { uri: "file:///a.md", coediting: false }, + ]); + }); + + it("persists the set across construction (reload survival)", () => { + const m = memento(); + const reg1 = new CoeditingRegistry(m as any); + reg1.enter({ toString: () => "file:///a.md" } as any); + const reg2 = new CoeditingRegistry(m as any); + expect(reg2.isCoediting({ toString: () => "file:///a.md" } as any)).toBe(true); + expect(reg2.list()).toEqual(["file:///a.md"]); + }); + + it("enter is idempotent (no duplicate events)", () => { + const reg = new CoeditingRegistry(memento() as any); + const events: unknown[] = []; + reg.onDidChange((e) => events.push(e)); + const uri = { toString: () => "file:///a.md" } as any; + reg.enter(uri); + reg.enter(uri); + expect(events).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run test/coeditingRegistry.test.ts` +Expected: FAIL — `Cannot find module '../src/coeditingRegistry'`. + +- [ ] **Step 3: Implement `src/coeditingRegistry.ts`** + +```typescript +/** + * CoeditingRegistry — the INV-10 activation gate (spec §6.4). Holds the set of + * documents the writer has explicitly entered into coediting; every surface + * (SCM/QuickDiff, comments, preview annotations, edit commands, decorations) + * checks membership before attaching. Persisted in workspaceState so a reload + * restores the set (the durable sidecar carries the artifacts; this carries + * only the mode). + */ +import * as vscode from "vscode"; + +const STATE_KEY = "cowriting.coeditingSet"; + +export class CoeditingRegistry implements vscode.Disposable { + private readonly set: Set; + private readonly emitter = new vscode.EventEmitter<{ uri: string; coediting: boolean }>(); + readonly onDidChange = this.emitter.event; + /** Set by the SCM surface (Task 3) so syncContext can expose the baseline mode. */ + baselineModeOf: ((uri: vscode.Uri) => "head" | "snapshot" | undefined) | undefined; + + constructor(private readonly state: vscode.Memento) { + this.set = new Set(state.get(STATE_KEY, [])); + } + + enter(uri: vscode.Uri): void { + const key = uri.toString(); + if (this.set.has(key)) return; + this.set.add(key); + void this.state.update(STATE_KEY, [...this.set]); + this.emitter.fire({ uri: key, coediting: true }); + } + + exit(uri: vscode.Uri): void { + const key = uri.toString(); + if (!this.set.delete(key)) return; + void this.state.update(STATE_KEY, [...this.set]); + this.emitter.fire({ uri: key, coediting: false }); + } + + isCoediting(uri: vscode.Uri): boolean { + return this.set.has(uri.toString()); + } + + list(): string[] { + return [...this.set]; + } + + syncContext(editor: vscode.TextEditor | undefined): void { + const coediting = !!editor && this.isCoediting(editor.document.uri); + void vscode.commands.executeCommand("setContext", "cowriting.isCoediting", coediting); + const mode = coediting && editor ? this.baselineModeOf?.(editor.document.uri) : undefined; + void vscode.commands.executeCommand("setContext", "cowriting.baselineMode", mode ?? ""); + } + + dispose(): void { + this.emitter.dispose(); + } +} +``` + +- [ ] **Step 4: Run the unit test — PASS.** `npx vitest run test/coeditingRegistry.test.ts` + +- [ ] **Step 5: Wire into `extension.ts`** + +In `src/extension.ts`, immediately after the `LiveProgressUi` block (line ~41), construct the registry and the enter/stop commands; add `coeditingRegistry: CoeditingRegistry` to `CowritingApi` and to the returned object: + +```typescript +import { CoeditingRegistry } from "./coeditingRegistry"; +// … +const coeditingRegistry = new CoeditingRegistry(context.workspaceState); +context.subscriptions.push(coeditingRegistry); +context.subscriptions.push( + vscode.commands.registerCommand("cowriting.coeditDocument", () => { + const ed = vscode.window.activeTextEditor; + if (!ed || ed.document.languageId !== "markdown" || !isAuthorable(ed.document.uri.scheme)) { + void vscode.window.showWarningMessage("Cowriting: open a Markdown document to coedit it."); + return; + } + coeditingRegistry.enter(ed.document.uri); + coeditingRegistry.syncContext(ed); + }), + vscode.commands.registerCommand("cowriting.stopCoediting", () => { + const ed = vscode.window.activeTextEditor; + if (!ed) return; + coeditingRegistry.exit(ed.document.uri); + coeditingRegistry.syncContext(ed); + }), + vscode.window.onDidChangeActiveTextEditor((ed) => coeditingRegistry.syncContext(ed)), +); +coeditingRegistry.syncContext(vscode.window.activeTextEditor); +``` + +- [ ] **Step 6: package.json — commands + context/title menus** + +Add to `contributes.commands`: + +```json +{ "command": "cowriting.coeditDocument", "title": "✦ Coedit this Document with Claude", "category": "Cowriting" }, +{ "command": "cowriting.stopCoediting", "title": "Stop editing with Claude", "category": "Cowriting" } +``` + +Add to `contributes.menus["editor/context"]`: + +```json +{ "command": "cowriting.coeditDocument", "when": "editorLangId == markdown && !cowriting.isCoediting", "group": "1_cowriting@1" }, +{ "command": "cowriting.stopCoediting", "when": "editorLangId == markdown && cowriting.isCoediting", "group": "1_cowriting@9" } +``` + +- [ ] **Step 7: Host E2E — enter/exit round-trip** + +```typescript +// test/e2e/suite/coediting.test.ts +import * as assert from "node:assert"; +import * as vscode from "vscode"; + +async function api() { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + return (await ext.activate()) as import("../../../src/extension").CowritingApi; +} + +suite("coediting registry (PUC-7)", () => { + test("enter → isCoediting true; exit → false; persists in list()", async () => { + const { coeditingRegistry } = await api(); + const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# t\n\nbody\n" }); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), true); + assert.ok(coeditingRegistry.list().includes(doc.uri.toString())); + await vscode.commands.executeCommand("cowriting.stopCoediting"); + assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), false); + }); +}); +``` + +(Use the extension id from package.json `publisher.name`; check `runTest.js` fixtures for the exact id used by existing suites and mirror it.) + +- [ ] **Step 8: Verify + commit** + +Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e` +Expected: all green (new suites included). + +```bash +git add src/coeditingRegistry.ts test/coeditingRegistry.test.ts test/e2e/suite/coediting.test.ts src/extension.ts package.json +git commit -m "feat: CoeditingRegistry opt-in gate (INV-10, spec §6.4) — enter/stop commands + context keys" +``` + +--- + +### Task 2: Baseline router — git HEAD + snapshot (INV-7), machine-landing advance retired + +**Files:** +- Create: `src/gitBaseline.ts` +- Modify: `src/diffViewController.ts`, `src/baselineStore.ts` (reason vocabulary), `src/extension.ts` (remove the `onDidApplyAgentEdit → advance` wire), `src/proposalController.ts` (finalize no longer signals a baseline advance) +- Test: `test/e2e/suite/baselineRouter.test.ts`; update `test/e2e/suite/diffView.test.ts` expectations + +**Interfaces:** +- Produces: + ```typescript + // src/gitBaseline.ts + export interface HeadBaseline { text: string; commit: string } + export class GitBaselineAdapter implements vscode.Disposable { + /** null until the git extension is available; resolves lazily. */ + async headFor(uri: vscode.Uri): Promise; // null = untracked/no repo/no HEAD blob + /** Fires when ANY watched repo's HEAD commit changes (debounced, keyed off state.HEAD.commit). */ + readonly onDidChangeHead: vscode.Event; + dispose(): void; + } + ``` +- `DiffViewController` gains/changes (consumed by Tasks 3–4 and the preview): + ```typescript + type BaselineReason = "entered" | "pinned" | "head"; // replaces opened|machine-landing|pinned + modeOf(uriString: string): "head" | "snapshot" | undefined; + async establish(document: vscode.TextDocument): Promise; // called by registry.enter wiring + pin(document: vscode.TextDocument): void; // snapshot mode only (D14) + // ensureBaseline(open-time capture) and advance(machine-landing) are REMOVED. + ``` + +- [ ] **Step 1: Write `src/gitBaseline.ts`** + +The Git extension API is typed inline (no dependency on git.d.ts). Key seams are spike-proven (spec §6.4 v0.2.1): + +```typescript +/** + * GitBaselineAdapter — resolves a document's git-HEAD baseline via the built-in + * Git extension API (spec §6.4, INV-7). Spike-verified: Repository.show('HEAD', + * path) + state.onDidChange work as assumed; the change event is coarse, so + * re-reads key off state.HEAD.commit; a nested repo may need openRepository(). + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +interface GitRepository { + rootUri: vscode.Uri; + state: { HEAD?: { commit?: string }; onDidChange: vscode.Event }; + show(ref: string, path: string): Promise; +} +interface GitAPI { + repositories: GitRepository[]; + getRepository(uri: vscode.Uri): GitRepository | null; + openRepository(root: vscode.Uri): Promise; + onDidOpenRepository: vscode.Event; +} + +export interface HeadBaseline { text: string; commit: string } + +export class GitBaselineAdapter implements vscode.Disposable { + private api: GitAPI | null | undefined; // undefined = not resolved yet; null = unavailable + private readonly hooked = new WeakSet(); + private readonly lastCommit = new Map(); // repo root → HEAD commit + private readonly emitter = new vscode.EventEmitter(); + readonly onDidChangeHead = this.emitter.event; + private readonly disposables: vscode.Disposable[] = [this.emitter]; + + private async gitApi(): Promise { + if (this.api !== undefined) return this.api; + try { + const ext = vscode.extensions.getExtension("vscode.git"); + if (!ext) return (this.api = null); + const exports = ext.isActive ? ext.exports : await ext.activate(); + this.api = exports.getAPI(1) as GitAPI; + this.disposables.push(this.api.onDidOpenRepository((r) => this.hook(r))); + for (const r of this.api.repositories) this.hook(r); + } catch { + this.api = null; + } + return this.api; + } + + /** HEAD blob text + commit for a tracked file; null when untracked / no repo / no HEAD. */ + async headFor(uri: vscode.Uri): Promise { + if (uri.scheme !== "file") return null; + const git = await this.gitApi(); + if (!git) return null; + let repo = git.getRepository(uri); + if (!repo) repo = await this.discoverNested(git, uri); + if (!repo) return null; + this.hook(repo); + try { + const text = await repo.show("HEAD", uri.fsPath); + return { text, commit: repo.state.HEAD?.commit ?? "" }; + } catch { + return null; // untracked, ignored, or no commit yet → snapshot mode + } + } + + /** Spike finding: a nested repo may not be auto-opened — walk up for .git, then openRepository. */ + private async discoverNested(git: GitAPI, uri: vscode.Uri): Promise { + let dir = path.dirname(uri.fsPath); + for (let i = 0; i < 32; i++) { + if (fs.existsSync(path.join(dir, ".git"))) { + await git.openRepository(vscode.Uri.file(dir)); + return git.getRepository(uri); + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; + } + + /** Watch a repo; fire onDidChangeHead only when HEAD's commit actually moves. */ + private hook(repo: GitRepository): void { + if (this.hooked.has(repo)) return; + this.hooked.add(repo); + this.lastCommit.set(repo.rootUri.toString(), repo.state.HEAD?.commit ?? ""); + this.disposables.push( + repo.state.onDidChange(() => { + const key = repo.rootUri.toString(); + const commit = repo.state.HEAD?.commit ?? ""; + if (this.lastCommit.get(key) !== commit) { + this.lastCommit.set(key, commit); + this.emitter.fire(); + } + }), + ); + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + } +} +``` + +- [ ] **Step 2: Rework `src/diffViewController.ts`** + +Changes (keep the class name and file — evolve in place): + +1. `BaselineReason` in `src/baselineStore.ts` becomes `"entered" | "pinned" | "head"`. On `store.load`, migrate legacy reasons: `"opened"`/`"machine-landing"` → `"entered"` (one `normalizeReason` helper in `baselineStore.ts`). +2. Constructor becomes `constructor(store: BaselineStore | null, git: GitBaselineAdapter, registry: CoeditingRegistry)`. Remove the `onDidOpenTextDocument → ensureBaseline` self-wiring and the open-time loop; instead: + ```typescript + this.disposables.push( + registry.onDidChange(({ uri, coediting }) => { + if (!coediting) return; // exit keeps the stored baseline (PUC-7) + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); + if (doc) void this.establish(doc); + }), + git.onDidChangeHead(() => void this.refreshHeadBaselines()), + ); + for (const key of registry.list()) { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); + if (doc) void this.establish(doc); + } + ``` +3. New `establish` (replaces `ensureBaseline`) resolves the mode: + ```typescript + private readonly modes = new Map(); + + async establish(document: vscode.TextDocument): Promise { + if (!this.isDiffable(document)) return; + const key = this.uriKey(document); + const head = await this.git.headFor(document.uri); + if (head) { + this.modes.set(key, "head"); + this.setBaseline(key, head.text, "head"); + return; + } + this.modes.set(key, "snapshot"); + if (this.store && this.isPersistable(document)) { + const stored = this.tryLoad(key); // existing load path, reasons normalized + if (stored) { this.baselines.set(key, stored); this.fire(key); return; } + } + this.capture(document, "entered"); + } + + modeOf(uriString: string): "head" | "snapshot" | undefined { + return this.modes.get(uriString); + } + + private async refreshHeadBaselines(): Promise { + for (const [key, mode] of this.modes) { + if (mode !== "head") continue; + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); + if (!doc) continue; + const head = await this.git.headFor(doc.uri); + if (head && head.text !== this.baselines.get(key)?.text) { + this.setBaseline(key, head.text, "head"); // commit advanced the baseline (D13) + } + } + } + + private setBaseline(key: string, text: string, reason: BaselineReason): void { + const baseline: Baseline = { uri: key, text, capturedAt: new Date().toISOString(), reason }; + this.baselines.set(key, baseline); + // head-mode baselines are storage-free (INV-7); persist only snapshots + if (reason !== "head" && this.store) this.trySave(key, baseline); + this.onDidChangeBaselineEmitter.fire({ uri: key }); + } + ``` +4. `pin()` guards on mode (D14): `if (this.modes.get(key) !== "snapshot") { warn "git-tracked — commit to advance the baseline"; return; }`. The command id `cowriting.pinDiffBaseline` is **renamed** `cowriting.markReviewed` (title "Mark Changes as Reviewed"); update `package.json` commands/menus and every test referencing `pinDiffBaseline`. +5. **Delete `advance()`** and the `#48`-era `"machine-landing"` branch; in `src/extension.ts` delete the wire `attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document))` (lines ~154–156). In `src/proposalController.ts`, `finalizeInPlace`'s call to `attribution.signalLanded(...)` must no longer trigger any baseline change — keep `signalLanded` (it still marks landing for attribution) but confirm nothing else subscribes for baseline purposes. Kept text now remains a change-since-baseline (spec INV-7 note, D21). +6. `getBaseline`/`baselineFilePath` keep their signatures (preview + F12 rely on them). The `reason === "pinned"` → clean rendering behavior (#48/INV-33) is preserved by keeping the `"pinned"` reason. + +- [ ] **Step 3: Update unit expectations** + +`test/` suites referencing `"opened"`/`"machine-landing"` reasons or `pinDiffBaseline` get updated to the new vocabulary (`git grep -l 'machine-landing\|pinDiffBaseline\|"opened"' test/ src/`). The pin→clean tests keep passing (reason `"pinned"` unchanged). + +- [ ] **Step 4: Host E2E — both baseline modes** + +```typescript +// test/e2e/suite/baselineRouter.test.ts +import * as assert from "node:assert"; +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +suite("baseline router (PUC-1, INV-7/D13/D14)", () => { + test("snapshot mode: enter captures; markReviewed re-pins clean", async () => { + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "one\n" }); + const ed = await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settle(); + assert.strictEqual(api.diffViewController.modeOf(doc.uri.toString()), "snapshot"); + assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "one\n"); + await ed.edit((b) => b.insert(new vscode.Position(1, 0), "two\n")); + await vscode.commands.executeCommand("cowriting.markReviewed"); + const b = api.diffViewController.getBaseline(doc.uri.toString()); + assert.strictEqual(b?.text, "one\ntwo\n"); + assert.strictEqual(b?.reason, "pinned"); + }); + + test("head mode: baseline = HEAD; commit advances it", async function () { + this.timeout(30000); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-git-")); + const git = (...a: string[]) => execFileSync("git", ["-C", dir, ...a], { encoding: "utf8" }); + git("init", "-q"); + fs.writeFileSync(path.join(dir, "doc.md"), "committed\n"); + git("add", "doc.md"); + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "c1"); + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(path.join(dir, "doc.md"))); + const ed = await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settleUntil(() => api.diffViewController.modeOf(doc.uri.toString()) === "head", 10000); + assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "committed\n"); + await ed.edit((b) => b.insert(new vscode.Position(1, 0), "uncommitted\n")); + await doc.save(); + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-aqm", "c2"); + await settleUntil( + () => api.diffViewController.getBaseline(doc.uri.toString())?.text === "committed\nuncommitted\n", + 15000, + ); + assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.reason, "head"); + }); +}); +// activateApi()/settle()/settleUntil() go in a shared test/e2e/suite/helpers.ts: +// activateApi = activate + return CowritingApi (mirror the pattern in the existing suites) +// settle = new Promise(r => setTimeout(r, 150)) +// settleUntil = poll a predicate every 100ms until true or timeout (assert on timeout) +``` + +- [ ] **Step 5: Verify + commit** + +Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e` +Expected: green, including both new baselineRouter tests (the head-mode one exercises the real vscode.git extension in the test host). + +```bash +git add src/gitBaseline.ts src/diffViewController.ts src/baselineStore.ts src/proposalController.ts src/extension.ts package.json test/ +git commit -m "feat: baseline router — git HEAD + snapshot per INV-7/D13/D14; retire machine-landing advance (spec §6.4)" +``` + +--- + +### Task 3: Native diff surface — cowriting-baseline:, QuickDiff, Review Changes, status bar + +**Files:** +- Create: `src/scmSurface.ts` +- Test: extend `test/e2e/suite/baselineRouter.test.ts` + new asserts in `coediting.test.ts` +- Modify: `src/extension.ts`, `package.json` (Review Changes / Mark Reviewed title-bar menus, status-bar-less commands) + +**Interfaces:** +- Consumes: `DiffViewController.getBaseline/modeOf/onDidChangeBaseline`, `CoeditingRegistry.isCoediting/onDidChange`. +- Produces: + ```typescript + export const BASELINE_SCHEME = "cowriting-baseline"; + export function baselineUriFor(docUri: vscode.Uri): vscode.Uri; // query = encodeURIComponent(docUri) + export class ScmSurfaceController implements vscode.Disposable { + constructor(registry: CoeditingRegistry, diffView: DiffViewController); + changeCount(uriString: string): number; // test seam + // commands registered: cowriting.reviewChanges + } + ``` + +- [ ] **Step 1: Unit test the change counter (pure)** + +The status-bar count is a line-hunk count between baseline and live text. Add a pure `countLineHunks(oldText, newText): number` to `src/trackChangesModel.ts` (it already owns the diff algebra — implement via the existing `diffBlocks`/`diff` machinery or a line-LCS; test first): + +```typescript +// append to test/trackChangesModel.test.ts +import { countLineHunks } from "../src/trackChangesModel"; + +describe("countLineHunks", () => { + it("0 for identical text", () => { + expect(countLineHunks("a\nb\n", "a\nb\n")).toBe(0); + }); + it("1 for one contiguous change", () => { + expect(countLineHunks("a\nb\nc\n", "a\nX\nc\n")).toBe(1); + }); + it("2 for two separated changes", () => { + expect(countLineHunks("a\nb\nc\nd\ne\n", "a\nX\nc\nd\nY\n")).toBe(2); + }); + it("counts pure insertions and deletions", () => { + expect(countLineHunks("a\n", "a\nb\n")).toBe(1); + expect(countLineHunks("a\nb\n", "a\n")).toBe(1); + }); +}); +``` + +Implement with the same LCS walk `diffBlocks` uses, at line grain (a run of non-equal lines = one hunk). + +- [ ] **Step 2: Implement `src/scmSurface.ts`** + +```typescript +/** + * ScmSurfaceController — the native diff surface (spec §6.4/§5, INV-13). + * Owns: the "Cowriting" SourceControl + QuickDiffProvider (gutter bars), the + * cowriting-baseline: TextDocumentContentProvider, the "Review Changes" native + * diff command, and the "✦ Coediting · N changes" status-bar item. Gated on + * the CoeditingRegistry (INV-10): non-entered docs resolve no original. + */ +import * as path from "node:path"; +import * as vscode from "vscode"; +import type { CoeditingRegistry } from "./coeditingRegistry"; +import type { DiffViewController } from "./diffViewController"; +import { countLineHunks } from "./trackChangesModel"; + +export const BASELINE_SCHEME = "cowriting-baseline"; + +export function baselineUriFor(docUri: vscode.Uri): vscode.Uri { + const name = path.basename(docUri.path) || "untitled.md"; + return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: `/${name}`, query: encodeURIComponent(docUri.toString()) }); +} + +export class ScmSurfaceController implements vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + private readonly statusItem: vscode.StatusBarItem; + private readonly counts = new Map(); + private readonly baselineEmitter = new vscode.EventEmitter(); + private readonly recountTimers = new Map>(); + + constructor( + private readonly registry: CoeditingRegistry, + private readonly diffView: DiffViewController, + ) { + this.statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + this.statusItem.command = "cowriting.reviewChanges"; + this.statusItem.tooltip = "Cowriting — click to review changes (baseline ⟷ live)"; + this.disposables.push(this.statusItem, this.baselineEmitter); + + this.disposables.push( + vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, { + onDidChange: this.baselineEmitter.event, + provideTextDocumentContent: (uri) => + this.diffView.getBaseline(decodeURIComponent(uri.query))?.text ?? "", + }), + ); + + const sc = vscode.scm.createSourceControl("cowriting", "✦ Cowriting"); + sc.quickDiffProvider = { + provideOriginalResource: (uri: vscode.Uri) => + this.registry.isCoediting(uri) && this.diffView.getBaseline(uri.toString()) + ? baselineUriFor(uri) + : null, + }; + this.disposables.push(sc); + + this.disposables.push( + vscode.commands.registerCommand("cowriting.reviewChanges", () => this.openReview()), + this.diffView.onDidChangeBaseline(({ uri }) => { + this.baselineEmitter.fire(baselineUriFor(vscode.Uri.parse(uri))); + this.scheduleRecount(uri); + }), + this.registry.onDidChange(({ uri, coediting }) => { + if (coediting) this.scheduleRecount(uri); + this.refreshStatus(); + }), + vscode.workspace.onDidChangeTextDocument((e) => { + if (this.registry.isCoediting(e.document.uri)) this.scheduleRecount(e.document.uri.toString()); + }), + vscode.window.onDidChangeActiveTextEditor(() => this.refreshStatus()), + ); + this.refreshStatus(); + } + + changeCount(uriString: string): number { + return this.counts.get(uriString) ?? 0; + } + + private async openReview(): Promise { + const ed = vscode.window.activeTextEditor; + if (!ed || !this.registry.isCoediting(ed.document.uri)) { + void vscode.window.showWarningMessage("Cowriting: not coediting this document."); + return; + } + this.baselineEmitter.fire(baselineUriFor(ed.document.uri)); + await vscode.commands.executeCommand( + "vscode.diff", + baselineUriFor(ed.document.uri), + ed.document.uri, + `${path.basename(ed.document.uri.path)} — Coediting (baseline ⟷ live)`, + { preview: true }, + ); + } + + private scheduleRecount(uriString: string): void { + const prev = this.recountTimers.get(uriString); + if (prev !== undefined) clearTimeout(prev); + this.recountTimers.set(uriString, setTimeout(() => { + this.recountTimers.delete(uriString); + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); + const baseline = this.diffView.getBaseline(uriString); + if (!doc || !baseline) return; + this.counts.set(uriString, countLineHunks(baseline.text, doc.getText())); + this.refreshStatus(); + }, 150)); + } + + private refreshStatus(): void { + const ed = vscode.window.activeTextEditor; + if (!ed || !this.registry.isCoediting(ed.document.uri)) { + this.statusItem.hide(); + return; + } + const n = this.counts.get(ed.document.uri.toString()) ?? 0; + this.statusItem.text = `$(sparkle) Coediting · ${n} change${n === 1 ? "" : "s"}`; + this.statusItem.show(); + } + + dispose(): void { + for (const t of this.recountTimers.values()) clearTimeout(t); + for (const d of this.disposables) d.dispose(); + } +} +``` + +- [ ] **Step 3: Wire + menus** + +`extension.ts`: construct after `diffViewController`; add to `CowritingApi` as `scmSurfaceController`. Also wire `registry.baselineModeOf = (uri) => diffViewController.modeOf(uri.toString())` (Task 1 hook) and call `registry.syncContext(...)` inside `diffView.onDidChangeBaseline` so the `cowriting.baselineMode` context key tracks mode changes. + +`package.json` — `editor/title` (replacing the `showTrackChangesPreview` entry in Task 8; for now add alongside): + +```json +{ "command": "cowriting.reviewChanges", "when": "resourceLangId == markdown && cowriting.isCoediting", "group": "navigation@1" }, +{ "command": "cowriting.markReviewed", "when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot", "group": "navigation@4" } +``` + +with icons on the command declarations: `"icon": "$(git-compare)"` (reviewChanges), `"icon": "$(check-all)"` (markReviewed). + +- [ ] **Step 4: E2E asserts** + +Extend `baselineRouter.test.ts`: after entering the snapshot doc and editing, `settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 1)`; after `cowriting.markReviewed`, count returns to 0. Add to `coediting.test.ts`: a **non-entered** markdown doc gets `provideOriginalResource` → null (assert via `api.scmSurfaceController.changeCount` staying 0 and, structurally, `registry.isCoediting === false` — QuickDiff itself isn't queryable from tests; assert the gate inputs). + +- [ ] **Step 5: Verify + commit** + +Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e` + +```bash +git add src/scmSurface.ts src/trackChangesModel.ts src/extension.ts package.json test/ +git commit -m "feat: native diff surface — QuickDiff + cowriting-baseline: provider + Review Changes + status bar (spec §6.4, INV-13)" +``` + +--- + +### Task 4: Gate every existing surface on the registry (INV-10) + +**Files:** +- Modify: `src/threadController.ts`, `src/attributionController.ts`, `src/proposalController.ts`, `src/editorProposalController.ts`, `src/extension.ts`, `package.json` +- Test: extend `test/e2e/suite/coediting.test.ts` + +**Interfaces:** +- Consumes: `CoeditingRegistry.isCoediting/onDidChange` (each controller gains a `registry: CoeditingRegistry` constructor param, appended last). +- Produces: no new API; behavior — non-entered docs get no commenting ranges, no attribution tracking, no proposal rendering, no CodeLens, no decorations. + +- [ ] **Step 1: ThreadController gating + the spike's re-assign refresh** + +In `threadController.ts`: + +```typescript +// constructor gains: private readonly registry: CoeditingRegistry +// keep ONE provider object; re-assign it on every registry change (spec §6.4 v0.2.1: +// re-assignment is the API's only "ranges changed" signal) +private readonly rangeProvider: vscode.CommentingRangeProvider = { + provideCommentingRanges: (document) => { + if (!isAuthorable(document.uri.scheme)) return []; + if (!this.registry.isCoediting(document.uri)) return []; + return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)]; + }, +}; +// in constructor: +this.controller.commentingRangeProvider = this.rangeProvider; +this.disposables.push( + this.registry.onDidChange(() => { + this.controller.commentingRangeProvider = this.rangeProvider; // force re-query + }), +); +``` + +Also gate `createThreadOnSelection` and `renderAll` early-return on `!this.registry.isCoediting(document.uri)` — and on `registry.onDidChange`, dispose rendered threads for exited docs / `renderAll` for entered ones (threads persist in the sidecar; exit hides, re-enter restores — PUC-7). + +- [ ] **Step 2: Attribution/Proposal/CodeLens gating** + +Same pattern, minimal touch: +- `attributionController.ts`: the `onDidChangeTextDocument` tracking listener and `loadAll` early-return when `!registry.isCoediting(doc.uri)`. +- `proposalController.ts`: `renderAll` + `onDidChangeProposals`-driven rendering gated; `propose()` itself stays ungated (the seam is only reachable from gated surfaces + the E2E harness). +- `editorProposalController.ts`: `provideCodeLenses` returns `[]` and `renderEditor` clears decorations when not coediting; re-render on `registry.onDidChange`. +- `extension.ts`: `renderIfOpen` checks the registry; the `cowriting.edit`/`editSelection`/`editDocument` commands warn `"Run ✦ Coedit this Document with Claude first."` when the target doc isn't entered. + +- [ ] **Step 3: package.json when-clauses** + +Every cowriting menu/keybinding entry that assumed always-on markdown gains `&& cowriting.isCoediting`: `cowriting.edit` (editor/context, editor/title/context, keybinding), `cowriting.createThread`, `cowriting.acceptAllProposals` / `cowriting.rejectAllProposals` palette gates. `cowriting.coeditDocument` keeps `!cowriting.isCoediting`. + +- [ ] **Step 4: E2E — the no-hijack scenario (PUC-7)** + +Extend `coediting.test.ts`: + +```typescript +test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => { + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# a\n\npara\n" }); + const ed = await vscode.window.showTextDocument(doc); + // not entered → no thread creation + ed.selection = new vscode.Selection(2, 0, 2, 4); + const before = await api.threadController.createThreadOnSelection("hi"); + assert.strictEqual(before, undefined); + // entered → works + await vscode.commands.executeCommand("cowriting.coeditDocument"); + const id = await api.threadController.createThreadOnSelection("hi"); + assert.ok(id); + // exit → rendered thread set empty; re-enter → restored from sidecar + await vscode.commands.executeCommand("cowriting.stopCoediting"); + assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 0); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settle(); + assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 1); +}); +``` + +Existing E2E suites (`authorship`, `f10Review`, `f12*`, `diffView`, `undoMarks`) assumed always-on: add `await vscode.commands.executeCommand("cowriting.coeditDocument")` to their setup helpers (do it once in the shared helper that opens the doc). + +- [ ] **Step 5: Verify + commit** + +Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e` + +```bash +git add src/ package.json test/ +git commit -m "feat: gate every surface on CoeditingRegistry (INV-10) — commenting ranges re-assigned on gate change (spec §6.4 v0.2.1)" +``` + +---### Task 5: Extract the edit flow from the webview controller + +**Files:** +- Create: `src/editFlow.ts` +- Modify: `src/trackChangesPreview.ts` (delegate to editFlow), `src/extension.ts` (acceptAll/rejectAll route to ProposalController directly) +- Test: existing `f10Review`/`f12*` suites keep passing (behavior-preserving refactor) + +**Interfaces:** +- Produces (Task 6 consumes `runEditAndPropose`; Task 8 deletes the webview knowing nothing else imports it): + ```typescript + // src/editFlow.ts + export type EditTarget = { kind: "document" } | { kind: "range"; start: number; end: number }; + export class EditFlow { + constructor(proposals: ProposalController, attribution: AttributionController, liveProgressUi: LiveProgressUi); + /** Moved verbatim from trackChangesPreview.ts lines 299–335 (one proposal per changed + * block for document edits — INV-39/40; block-union single proposal for range edits). */ + runEditAndPropose(document: vscode.TextDocument, target: EditTarget, instruction: string, + opts?: RunEditTurnOptions): Promise; + setEditTurnForTest(fn: EditTurn): void; // preserved test seam + } + ``` + +- [ ] **Step 1: Move, don't rewrite.** Cut `runEditAndPropose`, its `EditTarget`/`EditTurn` types, and the `cowriting.editDocument` command registration out of `trackChangesPreview.ts` into `src/editFlow.ts`; the preview controller receives the `EditFlow` instance and delegates (its webview `askClaude` messages call `editFlow.runEditAndPropose`). `extension.ts` constructs `EditFlow` before the preview controller, passes it to both the preview controller and (Task 6) the thread controller, and **adds `editFlow: EditFlow` to `CowritingApi`** (the Task 6/9 E2E suites drive `editFlow.setEditTurnForTest`). +- [ ] **Step 2: Re-route accept/reject-all.** `cowriting.acceptAllProposals` → `proposalController.acceptAllProposals(doc)`; `cowriting.rejectAllProposals` → `proposalController.rejectAll(doc)` (drop the preview-controller indirection; keep the same toasts by moving the report strings). +- [ ] **Step 3: Verify + commit.** `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e` — all existing suites green (pure refactor). + +```bash +git add src/editFlow.ts src/trackChangesPreview.ts src/extension.ts +git commit -m "refactor: extract EditFlow (runEditAndPropose + editDocument) from the review webview ahead of its sunset (spec §6.10)" +``` + +--- + +### Task 6: Comments-first ask + comment-response loop (D19/D10/D8); sunset the input webview + +**Files:** +- Modify: `src/threadController.ts` (the loop), `src/extension.ts` (Ask Claude command; editSelection prompt path), `package.json` (Ask Claude, Make-this-edit menus) +- Delete: `src/editInstructionInput.ts` (+ its media client if separate; `git grep editInstructionInput media/` first) +- Test: `test/e2e/suite/commentLoop.test.ts` + +**Interfaces:** +- Consumes: `EditFlow.runEditAndPropose`, `LiveProgressUi.begin`, `runEditTurn` (dynamic import), registry. +- Produces: + ```typescript + // ThreadController additions + askClaude(): Promise; // command cowriting.askClaude + /** Test seam: run the offer for a thread without the UI button. */ + makeThreadEdit(threadId: string, docPath: string): Promise; + setTurnRunnerForTest(fn: (instruction: string, context: string, opts?: RunEditTurnOptions) => Promise): void; + ``` + +- [ ] **Step 1: Ask Claude = focused comment box (spec §6.4 v0.2.1)** + +```typescript +// threadController.ts +async askClaude(): Promise { + const ed = vscode.window.activeTextEditor; + if (!ed || !this.registry.isCoediting(ed.document.uri)) { + void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first."); + return; + } + if (ed.selection.isEmpty) { + // whole-document ask → top-anchored thread (D19) + ed.selection = new vscode.Selection(0, 0, 0, 0); + ed.revealRange(new vscode.Range(0, 0, 0, 0)); + } + // Spike finding: workbench.action.addComment is the only path that opens the + // comment widget WITH its input focused (no thread.focus() API exists). + this.controller.commentingRangeProvider = this.rangeProvider; // defensive refresh + await vscode.commands.executeCommand("workbench.action.addComment"); +} +``` + +Register `cowriting.askClaude` in the constructor's command block. Command declaration: `{ "command": "cowriting.askClaude", "title": "Ask Claude", "category": "Cowriting", "icon": "$(sparkle)" }`; `editor/title` menu entry `when: "resourceLangId == markdown && cowriting.isCoediting"`, `group: "navigation@2"`. + +- [ ] **Step 2: Every comment on a coedited doc runs a turn (D10)** + +The existing `cowriting.reply` command (and thread creation via the `+` gutter → the contributed reply button) is the ingress. Extend `reply(r)`: after persisting the human message, if `this.registry.isCoediting(r.thread.uri)` and the author isn't the machine, fire the response loop: + +```typescript +private async respondInThread(vsThread: vscode.CommentThread, state: DocState, threadId: string, ask: string): Promise { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString()); + if (!doc) return; + const contextText = this.threadContextText(doc, vsThread); // passage for a ranged thread, full doc for top-anchored + try { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…", cancellable: true }, + async (progress, token) => { + const run = this.turnRunner ?? (await import("./liveTurn")).runEditTurn; + const ui = this.liveProgressUi.begin(ask, progress, token); + let turn: EditTurnResult; + try { + turn = await run(REPLY_PROMPT(ask), contextText, { onProgress: ui.onProgress, signal: ui.signal }); + } catch (err) { + if (token.isCancellationRequested) return; + throw err; + } + // machine reply, onBehalfOf provenance (INV-8) + appendMessage(state.artifact, threadId, { + author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId, onBehalfOf: this.currentAuthor() } }, + body: turn.replacement, + }); + this.persist(state); + this.refreshComments(vsThread, state, threadId); + vsThread.contextValue = "offer"; // when-key: commentThread == offer + this.offers.set(vsThread, { ask, threadId }); + }, + ); + } catch (err) { + void vscode.window.showErrorMessage(`Cowriting: Claude reply failed — ${err instanceof Error ? err.message : String(err)}`); + } +} +``` + +`REPLY_PROMPT(ask)` (module const): `"Reply conversationally (2-4 sentences) to this remark about the text, then, if the remark implies an edit, state the edit you would make. Remark: " + ask`. `threadContextText`: `doc.getText(vsThread.range)` when the thread has a non-empty range, else `doc.getText()`. + +- [ ] **Step 3: The offer → pending proposals (INV-5)** + +```typescript +// command cowriting.makeThreadEdit — menu arg is CommentReply or CommentThread +private async makeEditFromThread(arg: { thread: vscode.CommentThread } | vscode.CommentThread): Promise { + const vsThread = "thread" in arg ? arg.thread : arg; + const offer = this.offers.get(vsThread); + if (!offer || !this.registry.isCoediting(vsThread.uri)) return; + const doc = await vscode.workspace.openTextDocument(vsThread.uri); + const target = this.targetOf(doc, vsThread); // {kind:"range", start,end} for ranged threads, {kind:"document"} for top-anchored + const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask); + vsThread.contextValue = "offer-done"; + if (ids.length) void vscode.window.showInformationMessage( + `Cowriting: ${ids.length} pending change${ids.length === 1 ? "" : "s"} landed in the buffer — ✓ Keep / ✗ Reject there.`); +} +``` + +`ThreadController` constructor gains `editFlow: EditFlow` and `liveProgressUi: LiveProgressUi` params (extension.ts passes them). `offers` is a `WeakMap`. + +`package.json` menus (note the **`commentThread`** when-key — spec §6.4 v0.2.1): + +```json +"comments/commentThread/title": [ + { "command": "cowriting.makeThreadEdit", "when": "commentController == cowriting.threads && commentThread == offer", "group": "inline@1" }, + …existing resolve/reopen entries… +], +"comments/commentThread/context": [ + { "command": "cowriting.reply", "when": "commentController == cowriting.threads", "group": "inline@1" }, + { "command": "cowriting.makeThreadEdit", "when": "commentController == cowriting.threads && commentThread == offer", "group": "inline@2" } +] +``` + +with `{ "command": "cowriting.makeThreadEdit", "title": "✦ Make this edit", "icon": "$(sparkle)" }` in commands. + +- [ ] **Step 4: Replace the input webview at its two call sites** + +- `extension.ts` `cowriting.editSelection` (line ~254): replace `trackChangesPreviewController.askEditInstruction(...)` with creating a thread on the selection + focusing its input — the ask *is* the comment now (D19). Concretely: `await threadController.askClaude(); return;` — the loop (Step 2/3) takes over; delete the rest of the inline turn plumbing from that command (it now lives in the loop + EditFlow). Keep `cowriting.edit` routing (selection/document both end at `askClaude`). +- Delete `src/editInstructionInput.ts` and the preview controller's `askEditInstruction` default (point it at a rejected promise with a clear message; it dies fully in Task 8). `git grep -n "editInstructionInput\|promptEditInstruction\|askEditInstruction" src/ test/` must return only `trackChangesPreview.ts` internals slated for Task 8. + +- [ ] **Step 5: E2E — the loop with a stubbed turn (PUC-8)** + +```typescript +// test/e2e/suite/commentLoop.test.ts +suite("comment → reply → offer → proposal (PUC-8, D10/D19)", () => { + test("reply on a coedited doc summons a machine reply + offer; accept lands pending proposals", async () => { + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# T\n\nThe quick brown fox jumps over the lazy dog paragraph.\n" }); + const ed = await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + api.threadController.setTurnRunnerForTest(async () => ({ replacement: "I would tighten this sentence.", model: "stub", sessionId: "s1" })); + api.editFlow.setEditTurnForTest(async () => ({ replacement: "The quick fox jumps the lazy dog.", model: "stub", sessionId: "s1" })); + ed.selection = new vscode.Selection(2, 0, 2, 20); + const threadId = (await api.threadController.createThreadOnSelection("tighten this"))!; + // reply-loop fires on the human message; wait for the machine message to persist + await settleUntil(() => { + const t = api.sidecarRouter.load(api.proposalController.keyFor(doc))?.threads.find((x) => x.id === threadId); + return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent"; + }, 10000); + const ids = await api.threadController.makeThreadEdit(threadId, api.proposalController.keyFor(doc)); + assert.ok(ids.length >= 1); + assert.ok(api.proposalController.listProposals(doc).length >= 1); // pending, INV-5 + }); + + test("a comment on a NON-coedited doc summons nothing (INV-10)", async () => { + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "plain\n" }); + await vscode.window.showTextDocument(doc); + const id = await api.threadController.createThreadOnSelection("hello"); + assert.strictEqual(id, undefined); // gate refuses thread creation entirely + }); +}); +``` + +(Wire `createThreadOnSelection` to fire the same respond loop as `reply` when the doc is coedited — the first message of a new thread is also "a comment on a coedited doc", D10. `makeThreadEdit(threadId, docPath)` test-seam overload resolves the `vsThread` from `state.vsThreads`.) + +- [ ] **Step 6: Verify + commit** + +Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e` + +```bash +git add src/threadController.ts src/extension.ts package.json test/ && git rm src/editInstructionInput.ts +git commit -m "feat: comments-first ask + comment→reply→offer→proposal loop (D19/D10/D8, PUC-8); sunset the input webview (spec §6.10)" +``` + +--- + +### Task 7: Annotations in the built-in Markdown preview (D3/D21, PUC-3) + +**Files:** +- Create: `src/previewAnnotations.ts`, `media/preview-annotations.css` +- Modify: `src/extension.ts` (export `extendMarkdownIt` from `activate`'s return), `package.json` (`markdown.markdownItPlugins: true`, `markdown.previewStyles`, `cowriting.annotations` setting, Toggle command), `esbuild.mjs` only if the css needs copying (previewStyles paths are repo-relative — no build step needed) +- Test: `test/previewAnnotations.test.ts` (pure, vitest + markdown-it), `test/e2e/suite/previewAnnotations.test.ts` + +**Interfaces:** +- Consumes: `injectSentinels`, `sentinelsToSpans`, `wordEditHunks`, `landedTextOf` from `trackChangesModel.ts` (all existing pure exports); `AttributionController.spansFor`; `DiffViewController.getBaseline`; `ProposalController.listProposals`; registry. +- Produces: + ```typescript + // src/previewAnnotations.ts (pure module + one thin host hook) + export interface AnnotationInputs { + baselineText: string | undefined; + baselineReason: "entered" | "pinned" | "head" | undefined; + spans: AuthorSpan[]; + proposals: ProposalView[]; + enabled: boolean; + } + /** Pure: markdown source → sentinel-annotated source (authorship + change + proposal marks). */ + export function annotateSource(src: string, inputs: AnnotationInputs): string; + /** markdown-it plugin factory; host supplies inputs per render. */ + export function cowritingMarkdownItPlugin(md: any, host: { inputsFor(env: unknown): AnnotationInputs | undefined }): any; + ``` + +- [ ] **Step 1: Unit tests first (token-in → HTML-out, vscode-free)** + +```typescript +// test/previewAnnotations.test.ts +import MarkdownIt from "markdown-it"; +import { describe, expect, it } from "vitest"; +import { cowritingMarkdownItPlugin } from "../src/previewAnnotations"; + +function render(src: string, inputs: any): string { + const md = new MarkdownIt(); + cowritingMarkdownItPlugin(md, { inputsFor: () => inputs }); + return md.render(src); +} + +const base = { baselineReason: "entered", proposals: [], enabled: true }; + +describe("preview annotations (PUC-3/D21)", () => { + it("renders clean when disabled", () => { + const html = render("hello brave world\n", { ...base, enabled: false, baselineText: "hello world\n", spans: [] }); + expect(html).not.toContain("cw-"); + }); + it("colors machine spans blue and human insertions green vs baseline", () => { + const html = render("hello brave new world\n", { + ...base, + baselineText: "hello world\n", + spans: [{ start: 6, end: 12, author: "claude" }, { start: 12, end: 16, author: "human" }], + }); + expect(html).toContain('class="cw-ins-claude"'); + expect(html).toContain('class="cw-ins-human"'); + }); + it("strikes deletions vs baseline", () => { + const html = render("hello world\n", { ...base, baselineText: "hello cruel world\n", spans: [] }); + expect(html).toContain("cw-del"); + expect(html).toContain("cruel"); + }); + it("pinned baseline renders clean even with spans (pin→clean, INV-33/#48)", () => { + const html = render("hello brave world\n", { + ...base, baselineReason: "pinned", baselineText: "hello brave world\n", + spans: [{ start: 6, end: 12, author: "claude" }], + }); + expect(html).not.toContain("cw-ins"); + }); + it("survives intra-emphasis boundaries (#33 discipline)", () => { + const html = render("a *bold claim* here\n", { + ...base, baselineText: "a here\n", spans: [{ start: 2, end: 14, author: "claude" }], + }); + expect(html).toContain("cw-ins-claude"); + expect(html).not.toMatch(/[-]/); // no PUA sentinel leaks + }); +}); +``` + +- [ ] **Step 2: Implement `previewAnnotations.ts`** + +Mechanism (reuses the shipped sentinel discipline instead of inventing a new one): +1. `annotateSource` computes, against `baselineText` (skip everything when `enabled === false` or `baselineReason === "pinned"` with zero diff — the #48 rule): (a) **insertion marks** from `spans` clipped to changed regions (`wordEditHunks(current, baseline)` gives changed ranges; a span portion inside a changed range gets an `ins-` sentinel pair via `injectSentinels`); (b) **deletion marks**: for each hunk, the baseline-side dropped words are re-inserted into the source at the hunk start wrapped in `del` sentinels; (c) **pending proposals** already sit in the buffer text — mark them via their `anchorStart/anchorEnd` as `ins-claude` + their `original`'s dropped words as `del`. +2. `cowritingMarkdownItPlugin` installs (a) a core rule **before `normalize`** that swaps `state.src` for `annotateSource(state.src, inputs)` when `host.inputsFor(state.env)` yields inputs, and (b) a `text` renderer rule that runs the existing `sentinelsToSpans` token-aware walker (#33/#47-hardened) over the escaped token content so sentinel pairs become `` even across emphasis/tag boundaries. +3. The host hook in `extension.ts`: + ```typescript + const annotationHost = { + inputsFor(env: unknown): AnnotationInputs | undefined { + const envUri = (env as { currentDocument?: { toString(): string } })?.currentDocument?.toString(); + const key = envUri && coeditingRegistry.list().includes(envUri) ? envUri : lastActiveCoedited(); + if (!key) return undefined; + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); + if (!doc) return undefined; + return { + baselineText: diffViewController.getBaseline(key)?.text, + baselineReason: diffViewController.getBaseline(key)?.reason, + spans: attributionController.spansFor(doc), + proposals: proposalController.listProposals(doc), + enabled: vscode.workspace.getConfiguration("cowriting").get("annotations", true), + }; + }, + }; + // activate() return gains: + return { …api, extendMarkdownIt: (md: any) => cowritingMarkdownItPlugin(md, annotationHost) }; + ``` + (`env.currentDocument` is observed, not contractual — the `lastActiveCoedited()` fallback keeps single-doc correctness; record this in the module docstring.) +4. Toggle: setting `cowriting.annotations` (boolean, default true) + command `cowriting.toggleAnnotations` (icon `$(eye)`, title-bar `navigation@3`, gated `cowriting.isCoediting`) that flips the setting and runs `vscode.commands.executeCommand("markdown.preview.refresh")`. +5. `media/preview-annotations.css` (declared under `markdown.previewStyles`): the F10 class vocabulary — `.cw-ins-claude` (blue bg + underline), `.cw-ins-human` (green bg + underline), `.cw-del` (struck, red), light/dark variants keyed on `body.vscode-dark`. +6. **Mermaid (Q4 check, recorded):** the built-in preview does not render mermaid itself. Contribute `markdown.previewScripts` with the already-bundled mermaid (reusing the F7.1 pure re-emit: the markdown-it plugin re-emits changed-diagram sources through `mermaidDiff.ts` before the previewScript renders them). If `markdown.previewScripts` + bundled mermaid proves unworkable in the E2E step, **stop and record the Q4 finding in the spec before proceeding** — do not build a webview fallback silently (INV-3). + +- [ ] **Step 3: package.json** + +```json +"markdown.markdownItPlugins": true, +"markdown.previewStyles": ["./media/preview-annotations.css"], +"markdown.previewScripts": ["./media/preview-mermaid.js"], +"configuration": { "properties": { "cowriting.annotations": { "type": "boolean", "default": true, "description": "Show authorship + change annotations in the Markdown preview for coedited documents." }, …existing… } } +``` + +- [ ] **Step 4: E2E (structural asserts, §6.8 discipline)** + +The preview DOM isn't queryable from the host — assert the pure transform inputs/outputs instead: open + enter a doc, make a machine-attributed change (via the `cowriting.applyAgentEdit` harness seam), assert `annotateSource(doc.getText(), inputsFromApi(...))` contains `cw-ins-claude`; flip `cowriting.annotations` to false via `workspace.getConfiguration().update` and assert the transform returns the source unchanged. (Named scenario PUC-3 annotate-toggle.) + +- [ ] **Step 5: Verify + commit** + +Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e` + +```bash +git add src/previewAnnotations.ts media/preview-annotations.css src/extension.ts package.json test/ +git commit -m "feat: authorship + change annotations in the built-in Markdown preview (D3/D21, PUC-3); cowriting.annotations toggle" +``` + +--- + +### Task 8: Sunset the review-panel webview; surface cleanup + +**Files:** +- Delete: `src/trackChangesPreview.ts`, its webview client assets (`git grep -l "preview.js\|acquireVsCodeApi" media/ src/` to enumerate), `test/e2e/suite/f10Review.test.ts` (webview-bound asserts; fold its INV coverage into the surviving suites first — see Step 2) +- Modify: `src/extension.ts`, `package.json`, `src/editorProposalController.ts` (lens copy), `README.md` + +**Interfaces:** none new; removals — `TrackChangesPreviewController` leaves `CowritingApi`. + +- [ ] **Step 1: Re-point the entry surfaces.** `cowriting.showTrackChangesPreview` (command, ⌘⌥R keybinding, title/context/explorer menus) is replaced by: keybinding ⌘⌥R → `cowriting.reviewChanges`; the "Open Review Panel" context entries (#41) → `markdown.showPreviewToSide` preceded by `cowriting.coeditDocument` if not entered (one small wrapper command `cowriting.openReviewPreview`, title "Open Cowriting Review Preview"). Remove the `cowriting.editDocument` palette entry (the ask is comments-first now); keep the command registered for the E2E harness. +- [ ] **Step 2: Preserve INV coverage before deleting tests.** `f10Review.test.ts` covers accept/reject routing + INV-32/33 rendering: the routing asserts move to `commentLoop.test.ts`/`f12Accept.test.ts` (buffer surface), the render asserts to `test/previewAnnotations.test.ts` (pure). Do this move in the same commit as the deletion so coverage never drops. +- [ ] **Step 3: Delete** `src/trackChangesPreview.ts` + client assets; `extension.ts` drops its construction and the `askEditInstruction` stub; `EditFlow` (Task 5) already owns everything live. `git grep -n "trackChangesPreview\|TrackChangesPreviewController"` must return nothing. +- [ ] **Step 4: Lens copy (PUC-2 wording).** In `editorProposalController.ts` `provideCodeLenses`: per-proposal titles become `"✓ Keep"` / `"✗ Reject"` (commands unchanged: `finalizeInPlace`/`revertInPlace` via the menu handlers); add a top-of-file pair when ≥2 applied proposals: `"✓ Keep all (N)"` → `cowriting.acceptAllProposals`, `"✗ Reject all"` → `cowriting.rejectAllProposals`. Update `f12*` E2E title asserts. +- [ ] **Step 5: README (DOC-2).** Rewrite the "how it works" section as the native-surface map (spec §5 table): enter → title bar/status bar → comments-first ask → pending in buffer → native diff → built-in preview. One screenshot-free walkthrough. +- [ ] **Step 6: Verify + commit.** + +Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e` + +```bash +git add -A +git commit -m "feat!: sunset the review-panel webview — built-in preview + native diff are the review surfaces (D3/D17, spec §6.10); Keep/Reject lens copy (PUC-2)" +``` + +--- + +### Task 9: Full-loop E2E + live smoke (rung 3 folded in) + +**Files:** +- Create: `test/e2e/suite/fullLoop.test.ts`, `scripts/smoke-native-loop.mjs` +- Modify: `package.json` (script `smoke:native`) + +- [ ] **Step 1: The named full loop (§6.8), stub-turned:** one E2E that walks PUC-7 → PUC-8 → PUC-2 → PUC-1 in a single doc: enter (snapshot) → thread ask (stub turn) → offer → pending proposals decorated (assert `listProposals` + `isApplied`) → tweak by typing inside a pending range (assert re-anchor: proposal still resolvable) → Keep one (assert attribution split via `spansFor`: claude words + human tweak) → Reject one (assert buffer text equals pre-proposal text for that range **exactly** — INV-5) → `cowriting.markReviewed` → change count 0. +- [ ] **Step 2: Live smoke (rung 3 — the real SDK, manual gate):** `scripts/smoke-native-loop.mjs` mirrors `scripts/smoke-live-turn.mjs` but through the comment loop: launches the EDH, enters a sandbox doc, posts a real comment, waits for the real `@cline/sdk` reply + offer, accepts, prints the created proposal ids. `npm run smoke:native`. This is operator-run (real tokens); the plan's definition of done for rung 3 is one green run reported in the session transcript. +- [ ] **Step 3: Verify + commit.** + +```bash +git add test/e2e/suite/fullLoop.test.ts scripts/smoke-native-loop.mjs package.json +git commit -m "test: full native-loop E2E (PUC-1/2/7/8) + real-SDK smoke (spec §6.8, §7.1 rung 3)" +``` + +--- + +## Execution order & green-ness invariant + +Tasks run 1→9 strictly; every task leaves `typecheck + unit + host E2E` green. The two sunsets (Tasks 6, 8) each land only after their native replacement's tests pass in the same task (spec §6.10). No §9 web pipeline applies (VS Code extension — spec §7.2); "done" = acceptance (§1.9) + suites green + the Task 9 live smoke. + +## Deferred (recorded, not silently dropped) + +- **D15 — preview-initiated comments** (select in the rendered preview → native thread on the source, 💬 deep-link marker): the spike validated only the editor-selection and whole-document halves of E4; the preview-selection→source-map half was explicitly **not** spiked (spec §7.1 v0.2.1 note), and the built-in preview's script sandbox makes its feasibility a real question. It ships in a follow-up increment behind its own feasibility check; this plan's ask surfaces are the title-bar/editor-selection/whole-document paths. Reviewer may veto and pull it in. +- **PUC-4/6 thread re-anchor/displaced coverage** rides the existing F2 suites unchanged (the anchoring cores don't move in this migration). + +## Spec-coverage self-check (§ → task) + +- INV-7/D13/D14 baseline router → Tasks 2–3 · INV-10/PUC-7 gate → Tasks 1, 4 · INV-13 discovery → Task 3 · D19/D10/D8/PUC-8 comment loop → Task 6 · D3/D21/PUC-3 preview → Task 7 · D18/D20/PUC-2 buffer review → shipped (F12) + copy/gating in Tasks 4, 8 · INV-5/INV-12 → existing proposal seam, asserted in Task 9 · INV-8 onBehalfOf → Task 6 reply provenance · PUC-5 progress → shipped (#60), reused in Task 6 · §6.10 sunsets → Tasks 6, 8 · §6.8 named E2E → Tasks 2, 4, 6, 7, 9 · Q4 mermaid check → Task 7 Step 2.6 · DOC-2 README → Task 8.