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");
}
}