Files
vscode-cowriting-plugin/src/model.ts
T
Ben Stull 2f6008ba2b #47 (SLICE-2, review): per-block document proposals + word-precise accept
Document-edit flow SLICE-2 (specs/coauthoring-document-edit-flow.md §7.2,
INV-39/40/41 — P1 "too much to review"). A whole-document rewrite now proposes
ONE F4 proposal per CHANGED BLOCK (the unit a human reviews), but accepting a
block reconciles attribution at WORD granularity (the unit F3 records). Block =
decision unit; word = attribution unit. Supersedes INV-37's per-word cut for
document edits; selection edits unchanged.

- trackChangesModel.ts: new pure diffToBlockHunks(current, rewritten) — block-key
  alignment (reusing diffArrays/diffBlocks keying): an isolated changed block →
  one block-aligned hunk → the rewritten block raw (a code/mermaid fence is one
  atomic whole-fence hunk, INV-23); insert/delete runs → one gap-span hunk over
  the inter-anchor region (separators included) so reconstruction stays exact; a
  zero-width gap-span is anchored (INV-41). Also split diffToHunks into the raw,
  un-anchored wordEditHunks + the anchoring wrapper (the anchoring could grow an
  insertion to overlap an adjacent hunk, corrupting a batch apply — a latent bug
  that only surfaced once hunks are applied as a batch).
- trackChangesPreview.ts: runEditAndPropose document branch uses diffToBlockHunks
  and tags each proposal granularity:"block".
- model.ts / proposalModel.ts: additive optional Proposal.granularity
  ("block"|"single"; absent ⇒ single, back-compat — no migration).
- proposalController.ts: accept of a block proposal runs an intra-block word
  sub-diff (wordEditHunks, disjoint) and applies one applyAgentEdit per changed
  run, descending offset — only the words Claude changed land Claude-attributed;
  unchanged spans keep prior authorship (INV-40).
- Tests: diffToBlockHunks unit (reconstruction + fence atomic + add/remove);
  f12Review host E2E (M blocks→M proposals, unchanged→none, fence atomic, INV-40
  attribution, INV-41 insertion accept); updated the f11 document-path E2E to
  per-block (INV-39 supersedes INV-37); MANUAL-SMOKE-F12 §2.

Seam note: pendingEdits.matchEvent resolves one registration per change event, so
INV-40's per-run attribution is sequential applyAgentEdit calls (N undo steps),
not one multi-replace WorkspaceEdit — see transcript Deferred decisions.

214 unit + 69/5 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:01:16 -07:00

264 lines
7.8 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";
}
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,
),
),
},
a,
);
return JSON.stringify(canonical, null, 2) + "\n";
}