F3: live human/Claude attribution on @cline/sdk claude-code (Feature #6) #7

Merged
benstull merged 15 commits from feat/f3-live-attribution into main 2026-06-10 18:11:47 +00:00
5 changed files with 142 additions and 2 deletions
Showing only changes of commit decdfbf31d - Show all commits
+48
View File
@@ -0,0 +1,48 @@
/**
* PendingEditRegistry — the seam's bookkeeping (spec §6.2, INV-9). Before
* applyAgentEdit applies a WorkspaceEdit, it registers the exact expected
* change; the controller's change handler consumes a matching registration to
* attribute that change to the agent. Anything unmatched is human typing.
* vscode-free and unit-testable.
*/
import type { Provenance } from "./model";
export interface PendingEdit {
docPath: string;
/** half-open [start, end) offsets of the replaced range. */
start: number;
end: number;
newText: string;
provenance: Provenance;
turnId?: string;
}
export class PendingEditRegistry {
private pending: PendingEdit[] = [];
register(edit: PendingEdit): void {
this.pending.push(edit);
}
/** Remove a registration that failed to apply. */
unregister(edit: PendingEdit): void {
this.pending = this.pending.filter((p) => p !== edit);
}
/**
* Find-and-consume the registration exactly matching a change event
* (same doc, same replaced range, same inserted text). Null → human edit.
*/
match(docPath: string, change: { start: number; end: number; text: string }): PendingEdit | null {
const i = this.pending.findIndex(
(p) =>
p.docPath === docPath &&
p.start === change.start &&
p.end === change.end &&
p.newText === change.text,
);
if (i === -1) return null;
const [hit] = this.pending.splice(i, 1);
return hit;
}
}
+22 -1
View File
@@ -6,7 +6,7 @@
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { serializeArtifact, type Artifact } from "./model";
import { emptyArtifact, serializeArtifact, type Artifact } from "./model";
export class CoauthorStore {
/** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */
@@ -28,4 +28,25 @@ export class CoauthorStore {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, serializeArtifact(artifact), "utf8");
}
/**
* Read-modify-write for sidecar co-ownership (spec F3 §6.2): each controller
* 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).
*/
update(docPath: string, mutate: (artifact: Artifact) => void): Artifact {
const artifact = this.load(docPath) ?? emptyArtifact(docPath);
mutate(artifact);
const referenced = new Set<string>([
...artifact.threads.map((t) => t.anchorId),
...artifact.attributions.map((a) => a.anchorId),
]);
for (const id of Object.keys(artifact.anchors)) {
if (!referenced.has(id)) delete artifact.anchors[id];
}
this.save(docPath, artifact);
return artifact;
}
}
+6 -1
View File
@@ -84,7 +84,12 @@ export class ThreadController implements vscode.Disposable {
private persist(state: DocState): void {
this.selfWrites.add(this.store.sidecarPath(state.docPath));
this.store.save(state.docPath, state.artifact);
this.store.update(state.docPath, (a) => {
a.threads = state.artifact.threads;
for (const t of state.artifact.threads) {
a.anchors[t.anchorId] = state.artifact.anchors[t.anchorId];
}
});
}
private ensureState(document: vscode.TextDocument): DocState {
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import { PendingEditRegistry } from "../src/pendingEdits";
import type { Provenance } from "../src/model";
const AGENT: Provenance = {
kind: "agent", id: "claude",
agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" },
};
describe("PendingEditRegistry (INV-9)", () => {
it("matches and consumes an exact registration", () => {
const reg = new PendingEditRegistry();
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT, turnId: "t1" });
const hit = reg.match("d.md", { start: 3, end: 7, text: "new" });
expect(hit?.turnId).toBe("t1");
expect(reg.match("d.md", { start: 3, end: 7, text: "new" })).toBeNull();
});
it("does not match a different doc, range, or text (human typing stays human)", () => {
const reg = new PendingEditRegistry();
reg.register({ docPath: "d.md", start: 3, end: 7, newText: "new", provenance: AGENT });
expect(reg.match("other.md", { start: 3, end: 7, text: "new" })).toBeNull();
expect(reg.match("d.md", { start: 3, end: 8, text: "new" })).toBeNull();
expect(reg.match("d.md", { start: 3, end: 7, text: "neww" })).toBeNull();
});
it("unregister removes a failed application", () => {
const reg = new PendingEditRegistry();
const p = { docPath: "d.md", start: 0, end: 0, newText: "x", provenance: AGENT };
reg.register(p);
reg.unregister(p);
expect(reg.match("d.md", { start: 0, end: 0, text: "x" })).toBeNull();
});
});
+34
View File
@@ -54,4 +54,38 @@ describe("CoauthorStore", () => {
expect(second).toBe(first);
expect(first).toBe(serializeArtifact(a));
});
it("update(): two writers merge sections instead of clobbering (F3 §6.2)", () => {
const store = new CoauthorStore(root);
store.update("d.md", (a) => {
a.anchors["a_t"] = { fingerprint: { text: "t", before: "", after: "", lineHint: 0 } };
a.threads.push({ id: "t1", anchorId: "a_t", status: "open", messages: [] });
});
store.update("d.md", (a) => {
a.anchors["a_at"] = { fingerprint: { text: "x", before: "", after: "", lineHint: 0 } };
a.attributions.push({
id: "at1", anchorId: "a_at", author: { kind: "human", id: "ben" },
createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z",
});
});
const loaded = store.load("d.md")!;
expect(loaded.threads).toHaveLength(1);
expect(loaded.attributions).toHaveLength(1);
expect(Object.keys(loaded.anchors).sort()).toEqual(["a_at", "a_t"]);
});
it("update() prunes anchors no longer referenced by threads or attributions", () => {
const store = new CoauthorStore(root);
store.update("d.md", (a) => {
a.anchors["a_old"] = { fingerprint: { text: "o", before: "", after: "", lineHint: 0 } };
a.attributions.push({
id: "at1", anchorId: "a_old", author: { kind: "human", id: "ben" },
createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z",
});
});
store.update("d.md", (a) => {
a.attributions = [];
});
expect(Object.keys(store.load("d.md")!.anchors)).toEqual([]);
});
});