F2 SLICE-1: artifact schema + CoauthorStore with round-trip tests (#4)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-10 06:49:31 -07:00
parent 9c9d4928bd
commit 9d93ccca19
5 changed files with 271 additions and 0 deletions
+124
View File
@@ -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";
}
+31
View File
@@ -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");
}
}
+58
View File
@@ -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([]);
});
});
+57
View File
@@ -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));
});
});
+1
View File
@@ -4,5 +4,6 @@ export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
exclude: ["test/e2e/**", "node_modules/**"],
},
});