274 lines
8.4 KiB
TypeScript
274 lines
8.4 KiB
TypeScript
/**
|
|
* 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 `<del>` 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<string, Anchor>;
|
|
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<Artifact, "schemaVersion">): 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<string, unknown>, src: unknown): Record<string, unknown> {
|
|
const rec = src as Record<string, unknown>;
|
|
for (const k of Object.keys(rec)
|
|
.filter((k) => !(k in known))
|
|
.sort()) {
|
|
known[k] = rec[k];
|
|
}
|
|
return known;
|
|
}
|
|
|
|
function serializeProvenance(p: Provenance): Record<string, unknown> {
|
|
const known: Record<string, unknown> = {
|
|
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";
|
|
}
|