/** * 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 (contract §3.3). `agent` only present when * kind=agent. F5 (spec §6.3, fork c): `email` is the cross-rung join key — * git's own identity model (forges map email → account); `onBehalfOf` records * the operator a machine acted for (rfc-app#46 §6.5 made data). */ export type Provenance = | { kind: "human"; id: string; email?: string } | { kind: "agent"; id: string; email?: string; agent: { sdk: string; model: string; sessionId: string; onBehalfOf?: { id: string; email?: 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[]; } /** F3 (spec §6.3): an anchored, author-attributed span — state, not history. */ export interface AttributionRecord { id: string; /** shared anchors map — same Fingerprint primitive (INV-4). */ anchorId: string; author: Provenance; /** ISO-8601. */ createdAt: string; /** ISO-8601; bumped when the span's extent/fingerprint changes. */ updatedAt: string; /** groups all spans applied by one live turn. */ turnId?: string; } /** F4 (spec §6.3, INV-13): a PENDING machine edit — state, not history. */ export interface Proposal { id: string; /** shared anchors map; fingerprint.text IS the exact target text (INV-11). */ anchorId: string; /** the full proposed text for the anchored range. */ replacement: string; author: Provenance; /** ISO-8601. */ createdAt: string; /** groups N proposals born of one live turn. */ turnId?: string; /** what the human asked for (review context). */ instruction?: string; /** * F12/#47 (INV-39/40): the review-decision unit this proposal represents. * `"block"` ⇒ the anchor spans a whole document block and accept reconciles * attribution per WORD inside it (INV-40); `"single"` (or absent, for * back-compat with older sidecars) ⇒ a single-range proposal accepted whole. */ granularity?: "block" | "single"; /** * F12/#64 (INV-48): the pre-apply text, captured when a proposal is * optimistically applied to the buffer. `replacement` is now in the buffer and * `fp.text` re-anchors to it, so `original` is the only record of what to revert * to (revert-in-place) and what to show struck in the `` half. Absent on a * proposal created but not yet optimistically applied (or older sidecars). */ original?: string; } export interface Artifact { schemaVersion: number; /** repo-relative path; the sidecar key. */ document: { path: string }; /** SHARED primitive (INV-4). */ anchors: Record; threads: Thread[]; /** F3 fills this (spec §6.3, INV-4): typed attribution records anchored via anchors[]. */ attributions: AttributionRecord[]; /** F4 (spec §6.3, INV-13): pending proposals on shared anchors[] + Provenance. */ proposals: Proposal[]; } export function emptyArtifact(docPath: string): Artifact { return { schemaVersion: SCHEMA_VERSION, document: { path: docPath }, anchors: {}, threads: [], attributions: [], proposals: [], }; } /** * INV-16 fail-safe: schemaVersion IS the major. A sidecar from a future major * is render-only — a conforming writer never rewrites what it can't fully read. */ export function isNewerMajor(a: Pick): boolean { return a.schemaVersion > SCHEMA_VERSION; } /** Stable, generated id (spec §6.3). */ export function newId(prefix: string): string { return `${prefix}_${randomUUID()}`; } /** * INV-15 (round-trip preservation): after rebuilding the canonical known keys, * append any fields of `src` this writer does not recognize, sorted * lexicographically — no rung's writer may destroy another rung's data, and * placement stays deterministic (INV-2). Contract §2 rule 5 / §4 rule 3. */ function withUnknowns(known: Record, src: unknown): Record { const rec = src as Record; for (const k of Object.keys(rec) .filter((k) => !(k in known)) .sort()) { known[k] = rec[k]; } return known; } function serializeProvenance(p: Provenance): Record { const known: Record = { kind: p.kind, id: p.id, ...(p.email !== undefined ? { email: p.email } : {}), }; if (p.kind === "agent") { known.agent = withUnknowns( { sdk: p.agent.sdk, model: p.agent.model, sessionId: p.agent.sessionId, ...(p.agent.onBehalfOf !== undefined ? { onBehalfOf: withUnknowns( { id: p.agent.onBehalfOf.id, ...(p.agent.onBehalfOf.email !== undefined ? { email: p.agent.onBehalfOf.email } : {}), }, p.agent.onBehalfOf, ), } : {}), }, p.agent, ); } return withUnknowns(known, p); } /** * 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 = withUnknowns( { schemaVersion: a.schemaVersion, document: withUnknowns({ path: a.document.path }, a.document), anchors: Object.fromEntries( Object.keys(a.anchors) .sort() .map((k) => [ k, withUnknowns( { fingerprint: withUnknowns( { text: a.anchors[k].fingerprint.text, before: a.anchors[k].fingerprint.before, after: a.anchors[k].fingerprint.after, lineHint: a.anchors[k].fingerprint.lineHint, }, a.anchors[k].fingerprint, ), }, a.anchors[k], ), ]), ), threads: a.threads.map((t) => withUnknowns( { id: t.id, anchorId: t.anchorId, status: t.status, messages: t.messages.map((m) => withUnknowns( { id: m.id, author: serializeProvenance(m.author), body: m.body, createdAt: m.createdAt }, m, ), ), }, t, ), ), attributions: a.attributions.map((at) => withUnknowns( { id: at.id, anchorId: at.anchorId, author: serializeProvenance(at.author), createdAt: at.createdAt, updatedAt: at.updatedAt, ...(at.turnId !== undefined ? { turnId: at.turnId } : {}), }, at, ), ), proposals: a.proposals.map((p) => withUnknowns( { id: p.id, anchorId: p.anchorId, replacement: p.replacement, author: serializeProvenance(p.author), createdAt: p.createdAt, ...(p.turnId !== undefined ? { turnId: p.turnId } : {}), ...(p.instruction !== undefined ? { instruction: p.instruction } : {}), ...(p.original !== undefined ? { original: p.original } : {}), ...(p.granularity !== undefined ? { granularity: p.granularity } : {}), }, p, ), ), }, a, ); return JSON.stringify(canonical, null, 2) + "\n"; }