diff --git a/src/attributionController.ts b/src/attributionController.ts index 990e310..1f9158c 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -16,6 +16,7 @@ import { buildFingerprint, resolve, type OffsetRange } from "./anchorer"; import { gitUserEmail } from "./identity"; import { applyChange, coalesce, type LiveSpan } from "./attributionTracker"; import { minimizeReplace, PendingEditRegistry } from "./pendingEdits"; +import type { VersionGuard } from "./versionGuard"; /** Test-facing snapshot of live attribution state for a document. */ export interface RenderedSpan { @@ -62,7 +63,11 @@ export class AttributionController implements vscode.Disposable { private readonly output = vscode.window.createOutputChannel("Cowriting Attribution"); private visible = true; - constructor(private readonly store: CoauthorStore, private readonly rootDir: string) { + constructor( + private readonly store: CoauthorStore, + private readonly rootDir: string, + private readonly guard: VersionGuard, + ) { this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output); this.disposables.push( vscode.commands.registerCommand("cowriting.toggleAttribution", () => this.toggle()), @@ -277,6 +282,7 @@ export class AttributionController implements vscode.Disposable { private onDidSave(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; + if (this.guard.isReadOnly(this.docPathOf(document.uri))) return; const docPath = this.docPathOf(document.uri); const s = this.docs.get(docPath); // Allow save when hadAttributions is true even if spans/orphans are now empty: diff --git a/src/extension.ts b/src/extension.ts index a42d6b0..cb027b6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,7 @@ import { ThreadController } from "./threadController"; import { AttributionController } from "./attributionController"; import { ProposalController } from "./proposalController"; import { buildFingerprint } from "./anchorer"; +import { VersionGuard } from "./versionGuard"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; @@ -12,6 +13,7 @@ export interface CowritingApi { threadController: ThreadController; attributionController: AttributionController; proposalController: ProposalController; + versionGuard: VersionGuard; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { @@ -67,15 +69,18 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef return undefined; } const store = new CoauthorStore(root); - const threadController = new ThreadController(store, root); + // F5 (INV-16): one shared guard — newer-major sidecars are read-only, one + // warning per doc across the three co-owning controllers. + const versionGuard = new VersionGuard(store); + const threadController = new ThreadController(store, root, versionGuard); context.subscriptions.push(threadController); // --- F3: live attribution (Feature #6) --- - const attributionController = new AttributionController(store, root); + const attributionController = new AttributionController(store, root, versionGuard); context.subscriptions.push(attributionController); // --- F4: propose/accept (Feature #12) --- - const proposalController = new ProposalController(store, attributionController, root); + const proposalController = new ProposalController(store, attributionController, root, versionGuard); context.subscriptions.push(proposalController); // One SHARED sidecar watcher for both controllers; self-writes are @@ -228,7 +233,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef vscode.workspace.textDocuments.forEach(renderIfOpen); context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen)); - return { threadController, attributionController, proposalController }; + return { threadController, attributionController, proposalController, versionGuard }; } export function deactivate(): void { diff --git a/src/proposalController.ts b/src/proposalController.ts index 9295080..139c096 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -15,6 +15,7 @@ import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Pro import { resolve, shift, type OffsetRange } from "./anchorer"; import { addProposal, proposalBody, removeProposal } from "./proposalModel"; import type { AttributionController } from "./attributionController"; +import type { VersionGuard } from "./versionGuard"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedProposal { @@ -53,6 +54,7 @@ export class ProposalController implements vscode.Disposable { private readonly store: CoauthorStore, private readonly attribution: AttributionController, private readonly rootDir: string, + private readonly guard: VersionGuard, ) { // No commentingRangeProvider: humans never open proposal threads by hand — // proposals are born of machine turns only (INV-12 keeps decisions human). @@ -103,6 +105,7 @@ export class ProposalController implements vscode.Disposable { opts?: { turnId?: string; instruction?: string }, ): Promise { if (!this.isTracked(document)) return undefined; + if (this.guard.isReadOnly(this.docPathOf(document.uri))) return undefined; const docPath = this.docPathOf(document.uri); let proposalId: string | undefined; this.store.update(docPath, (a) => { @@ -137,6 +140,7 @@ export class ProposalController implements vscode.Disposable { } private async accept(state: DocState, proposal: Proposal): Promise { + if (this.guard.isReadOnly(state.docPath)) return false; const document = this.openDoc(state); if (!document) return false; // INV-11: the fingerprint-guard — exact re-resolve at decision time. @@ -167,6 +171,7 @@ export class ProposalController implements vscode.Disposable { } private reject(state: DocState, proposal: Proposal): void { + if (this.guard.isReadOnly(state.docPath)) return; this.store.update(state.docPath, (a) => removeProposal(a, proposal.id)); const document = this.openDoc(state); if (document) this.renderAll(document); diff --git a/src/threadController.ts b/src/threadController.ts index b929cf2..005e1a5 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -13,6 +13,7 @@ import { emptyArtifact, type Artifact, type Provenance } from "./model"; import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer"; import { gitUserEmail } from "./identity"; import { addThread, appendMessage, setStatus } from "./threadModel"; +import type { VersionGuard } from "./versionGuard"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedThread { @@ -39,7 +40,11 @@ export class ThreadController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); // keyed by docPath - constructor(private readonly store: CoauthorStore, private readonly rootDir: string) { + constructor( + private readonly store: CoauthorStore, + private readonly rootDir: string, + private readonly guard: VersionGuard, + ) { this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); this.controller.commentingRangeProvider = { provideCommentingRanges: (document) => { @@ -108,6 +113,7 @@ export class ThreadController implements vscode.Disposable { async createThreadOnSelection(firstBody = "New thread"): Promise { const editor = vscode.window.activeTextEditor; if (!editor || editor.selection.isEmpty || !this.isInRoot(editor.document.uri)) return undefined; + if (this.guard.isReadOnly(this.docPathOf(editor.document.uri))) return undefined; const document = editor.document; const state = this.ensureState(document); const offsets: OffsetRange = { @@ -127,6 +133,7 @@ export class ThreadController implements vscode.Disposable { const threadId = this.threadIdOf(r.thread); const state = this.stateOfThread(r.thread); if (!threadId || !state) return; + if (this.guard.isReadOnly(state.docPath)) return; appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text }); this.persist(state); this.refreshComments(r.thread, state, threadId); @@ -136,6 +143,7 @@ export class ThreadController implements vscode.Disposable { const threadId = this.threadIdOf(vsThread); const state = this.stateOfThread(vsThread); if (!threadId || !state) return; + if (this.guard.isReadOnly(state.docPath)) return; setStatus(state.artifact, threadId, status); this.persist(state); vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; @@ -198,6 +206,7 @@ export class ThreadController implements vscode.Disposable { private onDidSave(document: vscode.TextDocument): void { const state = this.docs.get(this.docPathOf(document.uri)); if (!state || state.live.size === 0) return; + if (this.guard.isReadOnly(state.docPath)) return; const text = document.getText(); let changed = false; for (const thread of state.artifact.threads) { diff --git a/src/versionGuard.ts b/src/versionGuard.ts new file mode 100644 index 0000000..76fc736 --- /dev/null +++ b/src/versionGuard.ts @@ -0,0 +1,35 @@ +/** + * VersionGuard — INV-16's editor face (spec §6.2/§6.4, PUC-4): a sidecar whose + * schemaVersion major is newer than this extension understands is READ-ONLY. + * Controllers render best-effort but skip every store.update path; the user is + * warned ONCE per document. One shared instance (extension.ts) because the + * sidecar is co-owned by three controllers — one warning, not three. The + * store's update() throw is the backstop; this gate is the UX. + */ +import * as vscode from "vscode"; +import { SCHEMA_VERSION, isNewerMajor } from "./model"; +import type { CoauthorStore } from "./store"; + +export class VersionGuard { + private readonly warned = new Set(); + + constructor(private readonly store: CoauthorStore) {} + + /** True → skip the write path (and warn once per doc). */ + isReadOnly(docPath: string): boolean { + const artifact = this.store.load(docPath); + if (!artifact || !isNewerMajor(artifact)) return false; + if (!this.warned.has(docPath)) { + this.warned.add(docPath); + void vscode.window.showWarningMessage( + `Cowriting: ${docPath}'s coauthoring sidecar is schemaVersion ${artifact.schemaVersion} — newer than this extension understands (${SCHEMA_VERSION}). Showing what it can; writing nothing (INV-16). Upgrade the extension to edit the record.`, + ); + } + return true; + } + + /** Test-facing: did the newer-major warning fire for this doc? */ + wasWarned(docPath: string): boolean { + return this.warned.has(docPath); + } +}