diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..c1113de --- /dev/null +++ b/src/model.ts @@ -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; + 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 { + 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"; +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..f96acd3 --- /dev/null +++ b/src/store.ts @@ -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/.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"); + } +} diff --git a/test/model.test.ts b/test/model.test.ts new file mode 100644 index 0000000..53d5805 --- /dev/null +++ b/test/model.test.ts @@ -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([]); + }); +}); diff --git a/test/store.test.ts b/test/store.test.ts new file mode 100644 index 0000000..20eb736 --- /dev/null +++ b/test/store.test.ts @@ -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/.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)); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index ed8bf77..1a98d14 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { environment: "node", include: ["test/**/*.test.ts"], + exclude: ["test/e2e/**", "node_modules/**"], }, });