F2: region-anchored threads on @cline/sdk (Feature #4) #5
+124
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Versioned coauthoring artifact schema (spec §6.3) — the shared git-native
|
||||||
|
* envelope under every coauthoring capability. `anchors` and the `Provenance`
|
||||||
|
* author field are SHARED primitives (INV-4): F3 (attributions) and F4
|
||||||
|
* (proposals) add arrays that reference the same shapes without a format break.
|
||||||
|
*
|
||||||
|
* vscode-free and pure, so it is unit-testable in Node (mirrors src/cline.ts).
|
||||||
|
*/
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
export const SCHEMA_VERSION = 1 as const;
|
||||||
|
|
||||||
|
/** Anchored text + bounded context + a line tie-breaker (spec §6.3). */
|
||||||
|
export interface Fingerprint {
|
||||||
|
text: string;
|
||||||
|
/** <= ~3 lines / 120 chars leading context. */
|
||||||
|
before: string;
|
||||||
|
/** <= ~3 lines / 120 chars trailing context. */
|
||||||
|
after: string;
|
||||||
|
/** 0-based line of the anchor start; a tie-breaker, NOT truth (INV-3). */
|
||||||
|
lineHint: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Anchor {
|
||||||
|
fingerprint: Fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reusable author field (spec §6.3). `agent` only present when kind=agent. */
|
||||||
|
export type Provenance =
|
||||||
|
| { kind: "human"; id: string }
|
||||||
|
| { kind: "agent"; id: string; agent: { sdk: string; model: string; sessionId: string } };
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
author: Provenance;
|
||||||
|
body: string;
|
||||||
|
/** ISO-8601. */
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThreadStatus = "open" | "resolved";
|
||||||
|
|
||||||
|
export interface Thread {
|
||||||
|
id: string;
|
||||||
|
anchorId: string;
|
||||||
|
status: ThreadStatus;
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Artifact {
|
||||||
|
schemaVersion: number;
|
||||||
|
/** repo-relative path; the sidecar key. */
|
||||||
|
document: { path: string };
|
||||||
|
/** SHARED primitive (INV-4). */
|
||||||
|
anchors: Record<string, Anchor>;
|
||||||
|
threads: Thread[];
|
||||||
|
/** F3 extension point — reuses anchors[]; not implemented in F2. */
|
||||||
|
attributions: unknown[];
|
||||||
|
/** F4 extension point — reuses anchors[] + provenance; not in F2. */
|
||||||
|
proposals: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyArtifact(docPath: string): Artifact {
|
||||||
|
return {
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
document: { path: docPath },
|
||||||
|
anchors: {},
|
||||||
|
threads: [],
|
||||||
|
attributions: [],
|
||||||
|
proposals: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable, generated id (spec §6.3). */
|
||||||
|
export function newId(prefix: string): string {
|
||||||
|
return `${prefix}_${randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeProvenance(p: Provenance): Record<string, unknown> {
|
||||||
|
return p.kind === "agent"
|
||||||
|
? { kind: p.kind, id: p.id, agent: { sdk: p.agent.sdk, model: p.agent.model, sessionId: p.agent.sessionId } }
|
||||||
|
: { kind: p.kind, id: p.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pretty-printed JSON with STABLE key ordering (anchors sorted by id), so
|
||||||
|
* sidecar diffs stay minimal and merge-friendly (spec §6.3, INV-2). Always
|
||||||
|
* ends with a trailing newline.
|
||||||
|
*/
|
||||||
|
export function serializeArtifact(a: Artifact): string {
|
||||||
|
const canonical = {
|
||||||
|
schemaVersion: a.schemaVersion,
|
||||||
|
document: { path: a.document.path },
|
||||||
|
anchors: Object.fromEntries(
|
||||||
|
Object.keys(a.anchors)
|
||||||
|
.sort()
|
||||||
|
.map((k) => [
|
||||||
|
k,
|
||||||
|
{
|
||||||
|
fingerprint: {
|
||||||
|
text: a.anchors[k].fingerprint.text,
|
||||||
|
before: a.anchors[k].fingerprint.before,
|
||||||
|
after: a.anchors[k].fingerprint.after,
|
||||||
|
lineHint: a.anchors[k].fingerprint.lineHint,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
threads: a.threads.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
anchorId: t.anchorId,
|
||||||
|
status: t.status,
|
||||||
|
messages: t.messages.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
author: serializeProvenance(m.author),
|
||||||
|
body: m.body,
|
||||||
|
createdAt: m.createdAt,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
attributions: a.attributions,
|
||||||
|
proposals: a.proposals,
|
||||||
|
};
|
||||||
|
return JSON.stringify(canonical, null, 2) + "\n";
|
||||||
|
}
|
||||||
@@ -0,0 +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).
|
||||||
|
*/
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { serializeArtifact, type Artifact } from "./model";
|
||||||
|
|
||||||
|
export class CoauthorStore {
|
||||||
|
/** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */
|
||||||
|
constructor(private readonly rootDir: string) {}
|
||||||
|
|
||||||
|
/** `.threads/<repo-relative-docPath>.json` (spec §6.3). */
|
||||||
|
sidecarPath(docPath: string): string {
|
||||||
|
return path.join(this.rootDir, ".threads", `${docPath}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
load(docPath: string): Artifact | null {
|
||||||
|
const p = this.sidecarPath(docPath);
|
||||||
|
if (!fs.existsSync(p)) return null;
|
||||||
|
return JSON.parse(fs.readFileSync(p, "utf8")) as Artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(docPath: string, artifact: Artifact): void {
|
||||||
|
const p = this.sidecarPath(docPath);
|
||||||
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
|
fs.writeFileSync(p, serializeArtifact(artifact), "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
SCHEMA_VERSION,
|
||||||
|
emptyArtifact,
|
||||||
|
serializeArtifact,
|
||||||
|
type Artifact,
|
||||||
|
} from "../src/model";
|
||||||
|
|
||||||
|
function sampleArtifact(): Artifact {
|
||||||
|
return {
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
document: { path: "docs/spec.md" },
|
||||||
|
anchors: {
|
||||||
|
a2: { fingerprint: { text: "beta", before: "x", after: "y", lineHint: 5 } },
|
||||||
|
a1: { fingerprint: { text: "alpha", before: "", after: "", lineHint: 1 } },
|
||||||
|
},
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
anchorId: "a1",
|
||||||
|
status: "open",
|
||||||
|
messages: [
|
||||||
|
{ id: "m1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attributions: [],
|
||||||
|
proposals: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("serializeArtifact", () => {
|
||||||
|
it("round-trips through JSON.parse", () => {
|
||||||
|
const a = sampleArtifact();
|
||||||
|
const parsed = JSON.parse(serializeArtifact(a)) as Artifact;
|
||||||
|
expect(parsed).toEqual(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits stable, sorted anchor keys and a trailing newline regardless of insertion order", () => {
|
||||||
|
const a = sampleArtifact();
|
||||||
|
const out = serializeArtifact(a);
|
||||||
|
expect(out.endsWith("\n")).toBe(true);
|
||||||
|
// a1 must serialize before a2 even though a2 was inserted first
|
||||||
|
expect(out.indexOf('"a1"')).toBeLessThan(out.indexOf('"a2"'));
|
||||||
|
// byte-identical on re-serialize
|
||||||
|
expect(serializeArtifact(JSON.parse(out))).toBe(out);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emptyArtifact has the shared F3/F4 extension points present and empty", () => {
|
||||||
|
const e = emptyArtifact("docs/x.md");
|
||||||
|
expect(e.schemaVersion).toBe(SCHEMA_VERSION);
|
||||||
|
expect(e.document.path).toBe("docs/x.md");
|
||||||
|
expect(e.anchors).toEqual({});
|
||||||
|
expect(e.threads).toEqual([]);
|
||||||
|
expect(e.attributions).toEqual([]);
|
||||||
|
expect(e.proposals).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { CoauthorStore } from "../src/store";
|
||||||
|
import { emptyArtifact, newId, serializeArtifact, type Artifact } from "../src/model";
|
||||||
|
|
||||||
|
let root: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = fs.mkdtempSync(path.join(os.tmpdir(), "coauthor-store-"));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function withThread(): Artifact {
|
||||||
|
const a = emptyArtifact("docs/spec.md");
|
||||||
|
a.anchors["a1"] = { fingerprint: { text: "hello", before: "", after: "", lineHint: 0 } };
|
||||||
|
a.threads.push({
|
||||||
|
id: "t1",
|
||||||
|
anchorId: "a1",
|
||||||
|
status: "open",
|
||||||
|
messages: [{ id: newId("m"), author: { kind: "human", id: "ben" }, body: "note", createdAt: "2026-06-10T00:00:00.000Z" }],
|
||||||
|
});
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CoauthorStore", () => {
|
||||||
|
it("returns null for a document with no sidecar", () => {
|
||||||
|
const store = new CoauthorStore(root);
|
||||||
|
expect(store.load("docs/spec.md")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes the sidecar path as .threads/<docPath>.json", () => {
|
||||||
|
const store = new CoauthorStore(root);
|
||||||
|
expect(store.sidecarPath("docs/spec.md")).toBe(path.join(root, ".threads", "docs", "spec.md.json"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("save then load round-trips the artifact", () => {
|
||||||
|
const store = new CoauthorStore(root);
|
||||||
|
const a = withThread();
|
||||||
|
store.save("docs/spec.md", a);
|
||||||
|
expect(store.load("docs/spec.md")).toEqual(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes byte-stable, pretty JSON (re-save is identical)", () => {
|
||||||
|
const store = new CoauthorStore(root);
|
||||||
|
const a = withThread();
|
||||||
|
store.save("docs/spec.md", a);
|
||||||
|
const first = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8");
|
||||||
|
store.save("docs/spec.md", store.load("docs/spec.md")!);
|
||||||
|
const second = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8");
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(first).toBe(serializeArtifact(a));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,5 +4,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["test/**/*.test.ts"],
|
include: ["test/**/*.test.ts"],
|
||||||
|
exclude: ["test/e2e/**", "node_modules/**"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user