From d05cb0f9d4ec02f5787e6955e58b5b2b1d14b78a Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 10:01:03 -0700 Subject: [PATCH] F3 SLICE-3: applyAgentEdit seam + live tracking + decorations/toggle (INV-7/INV-9) (#6) Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 7 +- src/attributionController.ts | 289 +++++++++++++++++++++++++++++++++++ src/extension.ts | 49 +++++- src/pendingEdits.ts | 6 +- src/store.ts | 22 ++- src/threadController.ts | 25 +-- 6 files changed, 372 insertions(+), 26 deletions(-) create mode 100644 src/attributionController.ts diff --git a/package.json b/package.json index 81d8865..5a65715 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,14 @@ }, { "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" }, { "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" }, - { "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" } + { "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" }, + { "command": "cowriting.toggleAttribution", "title": "Cowriting: Toggle Attribution", "category": "Cowriting" }, + { "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" } ], "menus": { + "commandPalette": [ + { "command": "cowriting.applyAgentEdit", "when": "false" } + ], "comments/commentThread/context": [ { "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" } ], diff --git a/src/attributionController.ts b/src/attributionController.ts new file mode 100644 index 0000000..8b713af --- /dev/null +++ b/src/attributionController.ts @@ -0,0 +1,289 @@ +/** + * AttributionController — the thin editor-facing layer for F3 (spec §6.2). + * Wires the pure AttributionTracker + PendingEditRegistry + Store/Anchorer to + * the editor: human typing → human spans, seam edits → agent spans (INV-9), + * decorations (Claude tint / human gutter border / toggle), save-time + * persistence, load/external-change resolve-or-orphan (INV-1/INV-6), and the + * orphan status-bar count. Sidecar self-write suppression and the shared + * FileSystemWatcher live in CoauthorStore / extension.ts (the sidecar is + * co-owned with ThreadController). + */ +import * as vscode from "vscode"; +import { CoauthorStore } from "./store"; +import { newId, type AttributionRecord, type Provenance } from "./model"; +import { buildFingerprint, resolve, type OffsetRange } from "./anchorer"; +import { applyChange, coalesce, type LiveSpan } from "./attributionTracker"; +import { PendingEditRegistry } from "./pendingEdits"; + +/** Test-facing snapshot of live attribution state for a document. */ +export interface RenderedSpan { + id: string; + authorKind: "human" | "agent"; + authorId: string; + turnId?: string; + range: { start: number; end: number }; +} + +interface DocAttribution { + docPath: string; + spans: LiveSpan[]; + /** persisted records whose fingerprints failed to resolve (PUC-4). */ + orphans: AttributionRecord[]; + /** record metadata per live span id (updatedAt bookkeeping). */ + records: Map; +} + +const AGENT_DECO: vscode.DecorationRenderOptions = { + backgroundColor: "rgba(99, 102, 241, 0.18)", + overviewRulerColor: "rgba(99, 102, 241, 0.8)", + overviewRulerLane: vscode.OverviewRulerLane.Right, +}; +const HUMAN_DECO: vscode.DecorationRenderOptions = { + borderColor: "rgba(16, 185, 129, 0.8)", + borderStyle: "solid", + borderWidth: "0 0 0 2px", +}; + +export class AttributionController implements vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + private readonly docs = new Map(); + private readonly pending = new PendingEditRegistry(); + private readonly agentType = vscode.window.createTextEditorDecorationType(AGENT_DECO); + private readonly humanType = vscode.window.createTextEditorDecorationType(HUMAN_DECO); + private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 90); + private readonly output = vscode.window.createOutputChannel("Cowriting Attribution"); + private visible = true; + + constructor(private readonly store: CoauthorStore, private readonly rootDir: string) { + this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output); + this.disposables.push( + vscode.commands.registerCommand("cowriting.toggleAttribution", () => this.toggle()), + vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), + vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), + vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()), + ); + } + + 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 currentAuthor(): Provenance { + const id = vscode.workspace.getConfiguration("git").get("user.name") || process.env.USER || "human"; + return { kind: "human", id }; + } + private state(docPath: string): DocAttribution { + let s = this.docs.get(docPath); + if (!s) { + s = { docPath, spans: [], orphans: [], records: new Map() }; + this.docs.set(docPath, s); + } + return s; + } + + // ---- PUC-4: load / resolve-or-orphan --------------------------------------------- + + /** Load the sidecar and re-resolve every attribution (live span | orphan). */ + loadAll(document: vscode.TextDocument): void { + if (!this.isTracked(document)) return; + const docPath = this.docPathOf(document.uri); + const s = this.state(docPath); + const artifact = this.store.load(docPath); + s.spans = []; + s.orphans = []; + s.records.clear(); + if (artifact) { + const text = document.getText(); + for (const rec of artifact.attributions) { + const fp = artifact.anchors[rec.anchorId]?.fingerprint; + const resolved = fp ? resolve(text, fp) : "orphaned"; + if (resolved === "orphaned") { + s.orphans.push(rec); + } else { + s.spans.push({ + id: rec.id, start: resolved.start, end: resolved.end, + author: rec.author, turnId: rec.turnId, createdAt: rec.createdAt, + }); + s.records.set(rec.id, rec); + } + } + s.spans = coalesce(s.spans); + } + this.render(document); + } + + /** Shared-watcher entry point (extension.ts): a sidecar changed externally. */ + handleExternalSidecarChange(uri: vscode.Uri): void { + for (const s of this.docs.values()) { + if (this.store.sidecarPath(s.docPath) === uri.fsPath) { + const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === s.docPath); + if (doc) this.loadAll(doc); + } + } + } + + // ---- PUC-1/PUC-3: live tracking --------------------------------------------------- + + private onDidChange(e: vscode.TextDocumentChangeEvent): void { + if (!this.isTracked(e.document) || e.contentChanges.length === 0) return; + const docPath = this.docPathOf(e.document.uri); + if (!e.document.isDirty) { + // Disk sync (revert / reload): re-resolve, never attribute (PUC-4). + this.loadAll(e.document); + return; + } + const s = this.state(docPath); + for (const change of e.contentChanges) { + const edit = { + start: change.rangeOffset, + end: change.rangeOffset + change.rangeLength, + newLength: change.text.length, + }; + const hit = this.pending.match(docPath, { start: edit.start, end: edit.end, text: change.text }); + const author = hit ? hit.provenance : this.currentAuthor(); + s.spans = applyChange(s.spans, edit, author, { + newId: () => newId("at"), + now: () => new Date().toISOString(), + turnId: hit?.turnId, + }); + } + this.render(e.document); + } + + // ---- the seam (INV-9) --------------------------------------------------------------- + + /** + * The ONLY machine-edit ingress: register the exact expected change, then + * apply it as a WorkspaceEdit. Returns false (applying nothing) on a stale + * document version or a rejected edit — never partial-applies (spec §6.9). + * The pending edit is unregistered unconditionally afterwards (a no-op when + * `match` already consumed it), so a no-op edit never leaks a registration. + */ + async applyAgentEdit( + document: vscode.TextDocument, + range: vscode.Range, + newText: string, + provenance: Provenance, + opts?: { expectedVersion?: number; turnId?: string }, + ): Promise { + if (!this.isTracked(document)) return false; + if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false; + const docPath = this.docPathOf(document.uri); + const pendingEdit = { + docPath, + start: document.offsetAt(range.start), + end: document.offsetAt(range.end), + newText, + provenance, + turnId: opts?.turnId, + }; + this.pending.register(pendingEdit); + const we = new vscode.WorkspaceEdit(); + we.replace(document.uri, range, newText); + const ok = await vscode.workspace.applyEdit(we); + this.pending.unregister(pendingEdit); + return ok; + } + + // ---- PUC-4: persistence on save ---------------------------------------------------- + + private onDidSave(document: vscode.TextDocument): void { + if (!this.isTracked(document)) return; + const docPath = this.docPathOf(document.uri); + const s = this.docs.get(docPath); + if (!s || (s.spans.length === 0 && s.orphans.length === 0)) return; + const text = document.getText(); + const now = new Date().toISOString(); + const records: AttributionRecord[] = []; + const anchorOf = new Map(); + for (const span of s.spans) { + const prev = s.records.get(span.id); + const rec: AttributionRecord = { + id: span.id, + anchorId: prev?.anchorId ?? newId("a"), + author: span.author, + createdAt: span.createdAt, + updatedAt: prev ? prev.updatedAt : now, + ...(span.turnId !== undefined ? { turnId: span.turnId } : {}), + }; + anchorOf.set(rec.anchorId, { start: span.start, end: span.end }); + records.push(rec); + s.records.set(span.id, rec); + } + this.store.update(docPath, (a) => { + for (const rec of records) { + const range = anchorOf.get(rec.anchorId)!; + const fp = buildFingerprint(text, range); + const prevFp = a.anchors[rec.anchorId]?.fingerprint; + if (prevFp && JSON.stringify(prevFp) !== JSON.stringify(fp)) rec.updatedAt = now; + a.anchors[rec.anchorId] = { fingerprint: fp }; + } + // Orphans ride along unchanged (recoverable, spec §6.9): their records + // stay in attributions[], so update()'s prune keeps their anchors too. + a.attributions = [...records, ...s.orphans]; + }); + } + + // ---- PUC-5: rendering ---------------------------------------------------------------- + + private toggle(): void { + this.visible = !this.visible; + this.renderActive(); + } + + private renderActive(): void { + const editor = vscode.window.activeTextEditor; + if (editor) this.render(editor.document); + } + + private render(document: vscode.TextDocument): void { + const editor = vscode.window.visibleTextEditors.find((e) => e.document === document); + if (!editor || !this.isTracked(document)) return; + const s = this.docs.get(this.docPathOf(document.uri)); + const spans = this.visible && s ? s.spans : []; + const toRange = (sp: LiveSpan) => + new vscode.Range(document.positionAt(sp.start), document.positionAt(sp.end)); + editor.setDecorations(this.agentType, spans.filter((x) => x.author.kind === "agent").map(toRange)); + editor.setDecorations(this.humanType, spans.filter((x) => x.author.kind === "human").map(toRange)); + this.renderStatus(s); + } + + private renderStatus(s: DocAttribution | undefined): void { + const n = s?.orphans.length ?? 0; + if (n === 0) { + this.statusItem.hide(); + return; + } + this.statusItem.text = `$(warning) ${n} orphaned attribution${n === 1 ? "" : "s"}`; + this.statusItem.tooltip = "Cowriting: attribution anchors that no longer resolve (see output channel)"; + this.statusItem.show(); + this.output.clear(); + for (const o of s!.orphans) this.output.appendLine(`orphaned ${o.id} (${o.author.kind}:${o.author.id})`); + } + + // ---- test-facing surface --------------------------------------------------------------- + + getSpans(docPath: string): RenderedSpan[] { + const s = this.docs.get(docPath); + if (!s) return []; + return s.spans.map((sp) => ({ + id: sp.id, + authorKind: sp.author.kind, + authorId: sp.author.id, + turnId: sp.turnId, + range: { start: sp.start, end: sp.end }, + })); + } + getOrphanCount(docPath: string): number { + return this.docs.get(docPath)?.orphans.length ?? 0; + } + isVisible(): boolean { + return this.visible; + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + } +} diff --git a/src/extension.ts b/src/extension.ts index 8671a27..eb20775 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,11 +2,13 @@ import * as vscode from "vscode"; import { fetchSdkSummary } from "./cline"; import { CoauthorStore } from "./store"; import { ThreadController } from "./threadController"; +import { AttributionController } from "./attributionController"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; export interface CowritingApi { threadController: ThreadController; + attributionController: AttributionController; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { @@ -41,14 +43,55 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const threadController = new ThreadController(store, root); context.subscriptions.push(threadController); - // Render threads for already-open editors, and on future opens. + // --- F3: live attribution (Feature #6) --- + const attributionController = new AttributionController(store, root); + context.subscriptions.push(attributionController); + + // One SHARED sidecar watcher for both controllers; self-writes are + // suppressed centrally in the store (the sidecar is co-owned). + const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); + const onSidecar = (uri: vscode.Uri) => { + if (store.consumeSelfWrite(uri.fsPath)) return; + threadController.handleExternalSidecarChange(uri); + attributionController.handleExternalSidecarChange(uri); + }; + watcher.onDidChange(onSidecar); + watcher.onDidCreate(onSidecar); + context.subscriptions.push(watcher); + + // The seam as a command (INV-9): the only machine-edit ingress, hidden from + // the palette — for the host E2E harness and future SDK turn plumbing. + context.subscriptions.push( + vscode.commands.registerCommand( + "cowriting.applyAgentEdit", + (args: { + uri: string; start: number; end: number; newText: string; + model?: string; sessionId?: string; turnId?: string; + }) => { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === args.uri); + if (!doc) return Promise.resolve(false); + const range = new vscode.Range(doc.positionAt(args.start), doc.positionAt(args.end)); + const provenance = { + kind: "agent" as const, + id: "claude", + agent: { sdk: "@cline/sdk", model: args.model ?? "sonnet", sessionId: args.sessionId ?? "" }, + }; + return attributionController.applyAgentEdit(doc, range, args.newText, provenance, { turnId: args.turnId }); + }, + ), + ); + + // Render threads + attributions for already-open editors, and on future opens. const renderIfOpen = (doc: vscode.TextDocument) => { - if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) threadController.renderAll(doc); + if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) { + threadController.renderAll(doc); + attributionController.loadAll(doc); + } }; vscode.workspace.textDocuments.forEach(renderIfOpen); context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen)); - return { threadController }; + return { threadController, attributionController }; } export function deactivate(): void { diff --git a/src/pendingEdits.ts b/src/pendingEdits.ts index 28c3995..e48bd32 100644 --- a/src/pendingEdits.ts +++ b/src/pendingEdits.ts @@ -24,7 +24,11 @@ export class PendingEditRegistry { this.pending.push(edit); } - /** Remove a registration that failed to apply. */ + /** + * Remove a registration that failed to apply. Identity-based (`===`), so + * callers must keep the exact registered object; it is a no-op when `match` + * already consumed the registration. + */ unregister(edit: PendingEdit): void { this.pending = this.pending.filter((p) => p !== edit); } diff --git a/src/store.ts b/src/store.ts index b9a93bd..6563e95 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,17 +1,31 @@ /** * CoauthorStore — load/save the per-document `.threads/` sidecar (spec §6.4). * Git-native, serverless, plain pretty JSON (INV-2). vscode-free (Node fs only), - * so it is unit-testable; the FileSystemWatcher that triggers re-anchoring on - * external change is wired in the vscode layer (ThreadController, SLICE-4). + * so it is unit-testable; the SHARED FileSystemWatcher that triggers + * re-anchoring on external change is wired in the vscode layer (extension.ts), + * with self-write suppression centralized here because two controllers + * (ThreadController, AttributionController) co-own the sidecar (F3 §6.2). */ import * as fs from "node:fs"; import * as path from "node:path"; import { emptyArtifact, serializeArtifact, type Artifact } from "./model"; export class CoauthorStore { + /** sidecar paths this store just wrote, so the shared watcher can ignore them. */ + private readonly selfWrites = new Set(); + /** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */ constructor(private readonly rootDir: string) {} + /** + * Delete-and-report: true iff `fsPath` was a sidecar we just wrote via + * `update`. The shared watcher (extension.ts) calls this to suppress + * self-write events before fanning out to the controllers. + */ + consumeSelfWrite(fsPath: string): boolean { + return this.selfWrites.delete(fsPath); + } + /** `.threads/.json` (spec §6.3). */ sidecarPath(docPath: string): string { return path.join(this.rootDir, ".threads", `${docPath}.json`); @@ -34,7 +48,8 @@ export class CoauthorStore { * mutates only its own section against a FRESH load, so ThreadController and * AttributionController never clobber each other. After the mutation, anchors * referenced by neither threads nor attributions are pruned (proposals are - * still empty in F3 — revisit in F4). + * still empty in F3 — revisit in F4). `mutate` MUST be synchronous: the + * read-modify-write (and the self-write mark) completes within this call. */ update(docPath: string, mutate: (artifact: Artifact) => void): Artifact { const artifact = this.load(docPath) ?? emptyArtifact(docPath); @@ -46,6 +61,7 @@ export class CoauthorStore { for (const id of Object.keys(artifact.anchors)) { if (!referenced.has(id)) delete artifact.anchors[id]; } + this.selfWrites.add(this.sidecarPath(docPath)); this.save(docPath, artifact); return artifact; } diff --git a/src/threadController.ts b/src/threadController.ts index f0a04f7..d0cf898 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -1,9 +1,11 @@ /** * ThreadController — the thin editor-facing layer (spec §6.4). Wires * CoauthorStore + Anchorer + threadModel to vscode.comments: create-on- - * selection, reply, resolve, render, live-range tracking, reload, and - * FileSystemWatcher-driven external re-anchoring (SLICE-4). Threads are NEVER - * silently moved — an unresolvable anchor renders as an orphaned thread (INV-1). + * selection, reply, resolve, render, live-range tracking, reload, and external + * re-anchoring (SLICE-4) — driven by the SHARED sidecar watcher in extension.ts + * via handleExternalSidecarChange (self-writes are suppressed in CoauthorStore). + * Threads are NEVER silently moved — an unresolvable anchor renders as an + * orphaned thread (INV-1). */ import * as vscode from "vscode"; import { CoauthorStore } from "./store"; @@ -35,8 +37,6 @@ export class ThreadController implements vscode.Disposable { private readonly controller: vscode.CommentController; private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); // keyed by docPath - /** sidecar paths we just wrote, to ignore our own watcher events. */ - private readonly selfWrites = new Set(); constructor(private readonly store: CoauthorStore, private readonly rootDir: string) { this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); @@ -60,13 +60,6 @@ export class ThreadController implements vscode.Disposable { vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), ); - - // Re-anchor on external change to any sidecar (a git pull, a manual edit). - const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); - const onSidecar = (uri: vscode.Uri) => this.onExternalSidecarChange(uri); - watcher.onDidChange(onSidecar); - watcher.onDidCreate(onSidecar); - this.disposables.push(watcher); } private isInRoot(uri: vscode.Uri): boolean { @@ -83,7 +76,6 @@ export class ThreadController implements vscode.Disposable { } private persist(state: DocState): void { - this.selfWrites.add(this.store.sidecarPath(state.docPath)); this.store.update(state.docPath, (a) => { a.threads = state.artifact.threads; for (const t of state.artifact.threads) { @@ -174,12 +166,9 @@ export class ThreadController implements vscode.Disposable { } } - private onExternalSidecarChange(uri: vscode.Uri): void { + /** Shared-watcher entry point (extension.ts): a sidecar changed externally. */ + handleExternalSidecarChange(uri: vscode.Uri): void { const p = uri.fsPath; - if (this.selfWrites.has(p)) { - this.selfWrites.delete(p); - return; // our own write - } for (const state of this.docs.values()) { if (this.store.sidecarPath(state.docPath) === p) { const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);