Merge pull request 'F5: cross-rung format contract + round-trip (#14)' (#15) from feature/14-cross-rung-format into main

This commit was merged in pull request #15.
This commit is contained in:
2026-06-11 06:07:39 +00:00
26 changed files with 2885 additions and 76 deletions
+33
View File
@@ -98,6 +98,39 @@ CI.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-propose-accept.md`.
Live smoke: [`docs/MANUAL-SMOKE-F4.md`](docs/MANUAL-SMOKE-F4.md).
## F5 — Cross-rung format + round-trip (Feature #14)
The `.threads/` sidecar is the **published cross-rung contract**: any rung of
the ladder (this editor → Gitea substrate → rfc-app) reads and writes the same
record per the contract; git push/pull is the transport, no re-homing ever.
- **Normative contract (INV-14):**
`vscode-cowriting-plugin-content/specs/coauthoring-sidecar-contract.md`
format changes land there first, then schema, then code.
- **Machine-checkable half:**
[`schemas/coauthoring-sidecar.schema.json`](schemas/coauthoring-sidecar.schema.json);
validate any sidecar from any rung with
`node scripts/validate-sidecar.mjs <file>`. The unit suite validates every
serialized artifact — contract drift fails CI.
- **Identity crosses rungs:** `Provenance.email` (git's own join key —
populated from the workspace git config when available) and
`agent.onBehalfOf` (who the machine acted for).
- **Writers preserve unknown fields (INV-15):** rewriting a sidecar never
destroys another rung's data — unknown keys survive, after known keys,
sorted.
- **Newer-major sidecars are read-only (INV-16):** the editor renders what it
understands, warns once, and writes nothing (the store refuses as a
backstop).
- **Deterministic merge (INV-17):** `src/mergeArtifacts.ts` — union-by-id,
documented tie-breaks, every resolved divergence surfaced in `conflicts`.
- **The round-trip is proven, not asserted:**
[`scripts/crossrung-reply.mjs`](scripts/crossrung-reply.mjs) is a
self-contained conforming foreign writer (the Gitea-rung stand-in); host E2E
drives editor-thread → stand-in reply → external-change → the reply renders
in the thread.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`.
## Develop
- `npm run watch` — rebuild on change.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -16,6 +16,7 @@
"@types/node": "^22.0.0",
"@types/vscode": "^1.90.0",
"@vscode/test-electron": "^2.4.0",
"ajv": "^8.20.0",
"esbuild": "^0.23.0",
"glob": "^11.0.0",
"mocha": "^10.7.0",
+73 -16
View File
@@ -10,9 +10,13 @@
"vscode": "^1.90.0",
"node": ">=22"
},
"categories": ["Other"],
"categories": [
"Other"
],
"main": "./out/extension.cjs",
"activationEvents": ["onStartupFinished"],
"activationEvents": [
"onStartupFinished"
],
"contributes": {
"commands": [
{
@@ -25,22 +29,70 @@
"title": "Cowriting: Add Coauthoring Thread on Selection",
"category": "Cowriting"
},
{ "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" },
{ "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" },
{ "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" },
{ "command": "cowriting.toggleAttribution", "title": "Toggle Attribution", "category": "Cowriting" },
{ "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" },
{ "command": "cowriting.editSelection", "title": "Ask Claude to Edit Selection", "category": "Cowriting" },
{ "command": "cowriting.acceptProposal", "title": "✓ Accept Proposal", "category": "Cowriting" },
{ "command": "cowriting.rejectProposal", "title": "✗ Reject Proposal", "category": "Cowriting" },
{ "command": "cowriting.proposeAgentEdit", "title": "Propose Agent Edit (internal seam)", "category": "Cowriting" }
{
"command": "cowriting.reply",
"title": "Reply",
"category": "Cowriting"
},
{
"command": "cowriting.resolveThread",
"title": "Resolve Thread",
"category": "Cowriting"
},
{
"command": "cowriting.reopenThread",
"title": "Reopen Thread",
"category": "Cowriting"
},
{
"command": "cowriting.toggleAttribution",
"title": "Toggle Attribution",
"category": "Cowriting"
},
{
"command": "cowriting.applyAgentEdit",
"title": "Apply Agent Edit (internal seam)",
"category": "Cowriting"
},
{
"command": "cowriting.editSelection",
"title": "Ask Claude to Edit Selection",
"category": "Cowriting"
},
{
"command": "cowriting.acceptProposal",
"title": "✓ Accept Proposal",
"category": "Cowriting"
},
{
"command": "cowriting.rejectProposal",
"title": "✗ Reject Proposal",
"category": "Cowriting"
},
{
"command": "cowriting.proposeAgentEdit",
"title": "Propose Agent Edit (internal seam)",
"category": "Cowriting"
}
],
"menus": {
"commandPalette": [
{ "command": "cowriting.applyAgentEdit", "when": "false" },
{ "command": "cowriting.proposeAgentEdit", "when": "false" },
{ "command": "cowriting.acceptProposal", "when": "false" },
{ "command": "cowriting.rejectProposal", "when": "false" }
{
"command": "cowriting.applyAgentEdit",
"when": "false"
},
{
"command": "cowriting.proposeAgentEdit",
"when": "false"
},
{
"command": "cowriting.acceptProposal",
"when": "false"
},
{
"command": "cowriting.rejectProposal",
"when": "false"
}
],
"editor/context": [
{
@@ -55,7 +107,11 @@
}
],
"comments/commentThread/context": [
{ "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" }
{
"command": "cowriting.reply",
"group": "inline",
"when": "commentController == cowriting.threads"
}
],
"comments/commentThread/title": [
{
@@ -99,6 +155,7 @@
"@types/node": "^22.0.0",
"@types/vscode": "^1.90.0",
"@vscode/test-electron": "^2.4.0",
"ajv": "^8.20.0",
"esbuild": "^0.23.0",
"glob": "^11.0.0",
"mocha": "^10.7.0",
+112
View File
@@ -0,0 +1,112 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.benstull.org/benstull/vscode-cowriting-plugin/schemas/coauthoring-sidecar.schema.json",
"title": "Coauthoring sidecar artifact — cross-rung contract, schemaVersion 1",
"description": "Machine-checkable half of the cross-rung contract (vscode-cowriting-plugin-content/specs/coauthoring-sidecar-contract.md — the prose contract is normative, INV-14). Unknown fields are deliberately allowed at every level: minor versions are additive and conforming writers preserve fields they do not recognize (INV-15/INV-16).",
"type": "object",
"required": ["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"],
"properties": {
"schemaVersion": { "type": "integer", "minimum": 1 },
"document": {
"type": "object",
"required": ["path"],
"properties": { "path": { "type": "string", "minLength": 1 } }
},
"anchors": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/anchor" }
},
"threads": { "type": "array", "items": { "$ref": "#/$defs/thread" } },
"attributions": { "type": "array", "items": { "$ref": "#/$defs/attribution" } },
"proposals": { "type": "array", "items": { "$ref": "#/$defs/proposal" } }
},
"$defs": {
"fingerprint": {
"type": "object",
"required": ["text", "before", "after", "lineHint"],
"properties": {
"text": { "type": "string" },
"before": { "type": "string" },
"after": { "type": "string" },
"lineHint": { "type": "integer", "minimum": 0 }
}
},
"anchor": {
"type": "object",
"required": ["fingerprint"],
"properties": { "fingerprint": { "$ref": "#/$defs/fingerprint" } }
},
"onBehalfOf": {
"type": "object",
"required": ["id"],
"properties": { "id": { "type": "string" }, "email": { "type": "string" } }
},
"agentPayload": {
"type": "object",
"required": ["sdk", "model", "sessionId"],
"properties": {
"sdk": { "type": "string" },
"model": { "type": "string" },
"sessionId": { "type": "string" },
"onBehalfOf": { "$ref": "#/$defs/onBehalfOf" }
}
},
"provenance": {
"type": "object",
"required": ["kind", "id"],
"properties": {
"kind": { "enum": ["human", "agent"] },
"id": { "type": "string" },
"email": { "type": "string" },
"agent": { "$ref": "#/$defs/agentPayload" }
},
"if": { "properties": { "kind": { "const": "agent" } } },
"then": { "required": ["kind", "id", "agent"] }
},
"message": {
"type": "object",
"required": ["id", "author", "body", "createdAt"],
"properties": {
"id": { "type": "string" },
"author": { "$ref": "#/$defs/provenance" },
"body": { "type": "string" },
"createdAt": { "type": "string" }
}
},
"thread": {
"type": "object",
"required": ["id", "anchorId", "status", "messages"],
"properties": {
"id": { "type": "string" },
"anchorId": { "type": "string" },
"status": { "enum": ["open", "resolved"] },
"messages": { "type": "array", "items": { "$ref": "#/$defs/message" } }
}
},
"attribution": {
"type": "object",
"required": ["id", "anchorId", "author", "createdAt", "updatedAt"],
"properties": {
"id": { "type": "string" },
"anchorId": { "type": "string" },
"author": { "$ref": "#/$defs/provenance" },
"createdAt": { "type": "string" },
"updatedAt": { "type": "string" },
"turnId": { "type": "string" }
}
},
"proposal": {
"type": "object",
"required": ["id", "anchorId", "replacement", "author", "createdAt"],
"properties": {
"id": { "type": "string" },
"anchorId": { "type": "string" },
"replacement": { "type": "string" },
"author": { "$ref": "#/$defs/provenance" },
"createdAt": { "type": "string" },
"turnId": { "type": "string" },
"instruction": { "type": "string" }
}
}
}
}
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
// crossrung-reply — the Gitea-rung STAND-IN writer (F5 spec §6.2, fork e):
// exactly what a forge-side surface does to the record, as a local process.
// Conforming-writer behavior per the contract (vscode-cowriting-plugin-content/
// specs/coauthoring-sidecar-contract.md §5): validate → refuse newer majors →
// append a Message (fresh uuid, ISO-8601 UTC, identity w/ email) →
// stable-serialize (known-key order, unknowns after sorted, 2-space, trailing
// newline) → validate again → write.
//
// This deliberately RE-IMPLEMENTS the contract's serialization instead of
// importing the extension's serializer: it is executable documentation of how
// a second rung behaves. test/crossrungReply.test.ts pins byte-fidelity
// against the reference serializer — the contract-drift tripwire.
//
// usage:
// node scripts/crossrung-reply.mjs <sidecar.json> <threadId> <body> \
// [--author-id id] [--author-email email]
import { readFileSync, writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import path from "node:path";
import Ajv from "ajv/dist/2020.js";
const here = path.dirname(fileURLToPath(import.meta.url));
const schema = JSON.parse(readFileSync(path.join(here, "..", "schemas", "coauthoring-sidecar.schema.json"), "utf8"));
const ajv = new Ajv({ allErrors: true });
const checks = ajv.compile(schema);
function fail(msg) {
console.error(`crossrung-reply: ${msg}`);
process.exit(1);
}
function validateOrFail(data, when) {
if (!checks(data)) {
fail(
`${when} validation failed:\n` +
(checks.errors ?? []).map((e) => ` ${e.instancePath || "/"} ${e.message}`).join("\n"),
);
}
}
// --- args ---
const [file, threadId, body] = process.argv.slice(2, 5);
if (!file || !threadId || body === undefined) {
console.error("usage: crossrung-reply.mjs <sidecar.json> <threadId> <body> [--author-id id] [--author-email email]");
process.exit(2);
}
const flags = process.argv.slice(5);
const flagVal = (name) => {
const i = flags.indexOf(name);
return i >= 0 ? flags[i + 1] : undefined;
};
const authorId = flagVal("--author-id") ?? "crossrung";
const authorEmail = flagVal("--author-email");
// --- contract serialization (independent implementation: contract §2) ---
const ORDER = {
root: ["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"],
document: ["path"],
anchor: ["fingerprint"],
fingerprint: ["text", "before", "after", "lineHint"],
thread: ["id", "anchorId", "status", "messages"],
message: ["id", "author", "body", "createdAt"],
provenance: ["kind", "id", "email", "agent"],
agent: ["sdk", "model", "sessionId", "onBehalfOf"],
onBehalfOf: ["id", "email"],
attribution: ["id", "anchorId", "author", "createdAt", "updatedAt", "turnId"],
proposal: ["id", "anchorId", "replacement", "author", "createdAt", "turnId", "instruction"],
};
// known keys first (contract order, present ones only), unknown keys after, sorted
function ordered(obj, kind, mapKnown = {}) {
const out = {};
const known = ORDER[kind] ?? [];
for (const k of known) if (k in obj) out[k] = k in mapKnown ? mapKnown[k](obj[k]) : obj[k];
for (const k of Object.keys(obj).filter((k) => !known.includes(k)).sort()) out[k] = obj[k];
return out;
}
const provenance = (p) =>
ordered(p, "provenance", {
agent: (ag) => ordered(ag, "agent", { onBehalfOf: (o) => ordered(o, "onBehalfOf") }),
});
function canonicalize(a) {
return ordered(a, "root", {
document: (d) => ordered(d, "document"),
anchors: (anchors) =>
Object.fromEntries(
Object.keys(anchors)
.sort()
.map((k) => [k, ordered(anchors[k], "anchor", { fingerprint: (fp) => ordered(fp, "fingerprint") })]),
),
threads: (ts) =>
ts.map((t) => ordered(t, "thread", { messages: (ms) => ms.map((m) => ordered(m, "message", { author: provenance })) })),
attributions: (ats) => ats.map((at) => ordered(at, "attribution", { author: provenance })),
proposals: (ps) => ps.map((p) => ordered(p, "proposal", { author: provenance })),
});
}
const serialize = (a) => JSON.stringify(canonicalize(a), null, 2) + "\n";
// --- the write (contract §5 checklist) ---
const data = JSON.parse(readFileSync(file, "utf8"));
validateOrFail(data, "pre-write");
if (data.schemaVersion > 1) {
fail(`schemaVersion ${data.schemaVersion} is newer than this writer understands (1) — refusing to write (INV-16)`);
}
const thread = data.threads.find((t) => t.id === threadId);
if (!thread) fail(`no thread ${threadId} in ${file}`);
thread.messages.push({
id: `m_${randomUUID()}`,
author: { kind: "human", id: authorId, ...(authorEmail ? { email: authorEmail } : {}) },
body,
createdAt: new Date().toISOString(),
});
const out = serialize(data);
validateOrFail(JSON.parse(out), "post-write");
writeFileSync(file, out, "utf8");
console.log(`appended reply to ${threadId} in ${file}`);
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
// validate-sidecar — rung-neutral conformance check (spec §6.4, PUC-5).
// usage:
// node scripts/validate-sidecar.mjs <sidecar.json>
// exit 0 = valid, 1 = invalid (per-error JSON paths on stderr), 2 = usage/IO.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import Ajv from "ajv/dist/2020.js";
const here = path.dirname(fileURLToPath(import.meta.url));
const schema = JSON.parse(readFileSync(path.join(here, "..", "schemas", "coauthoring-sidecar.schema.json"), "utf8"));
const file = process.argv[2];
if (!file) {
console.error("usage: validate-sidecar.mjs <sidecar.json>");
process.exit(2);
}
let data;
try {
data = JSON.parse(readFileSync(file, "utf8"));
} catch (err) {
console.error(`cannot read/parse ${file}: ${err.message}`);
process.exit(2);
}
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
if (validate(data)) {
console.log(`OK ${file} (schemaVersion ${data.schemaVersion})`);
process.exit(0);
}
for (const e of validate.errors ?? []) console.error(`${e.instancePath || "/"} ${e.message}`);
process.exit(1);
+10 -2
View File
@@ -13,8 +13,10 @@ import * as vscode from "vscode";
import { CoauthorStore } from "./store";
import { newId, type AttributionRecord, type Provenance } from "./model";
import { buildFingerprint, resolve, type OffsetRange } from "./anchorer";
import { gitUserEmail } from "./identity";
import { applyChange, coalesce, type LiveSpan } from "./attributionTracker";
import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
import type { VersionGuard } from "./versionGuard";
/** Test-facing snapshot of live attribution state for a document. */
export interface RenderedSpan {
@@ -61,7 +63,11 @@ export class AttributionController implements vscode.Disposable {
private readonly output = vscode.window.createOutputChannel("Cowriting Attribution");
private visible = true;
constructor(private readonly store: CoauthorStore, private readonly rootDir: string) {
constructor(
private readonly store: CoauthorStore,
private readonly rootDir: string,
private readonly guard: VersionGuard,
) {
this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output);
this.disposables.push(
vscode.commands.registerCommand("cowriting.toggleAttribution", () => this.toggle()),
@@ -79,7 +85,8 @@ export class AttributionController implements vscode.Disposable {
}
private currentAuthor(): Provenance {
const id = vscode.workspace.getConfiguration("git").get<string>("user.name") || process.env.USER || "human";
return { kind: "human", id };
const email = gitUserEmail(this.rootDir);
return { kind: "human", id, ...(email !== undefined ? { email } : {}) };
}
private state(docPath: string): DocAttribution {
let s = this.docs.get(docPath);
@@ -275,6 +282,7 @@ export class AttributionController implements vscode.Disposable {
private onDidSave(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return;
if (this.guard.isReadOnly(this.docPathOf(document.uri))) return;
const docPath = this.docPathOf(document.uri);
const s = this.docs.get(docPath);
// Allow save when hadAttributions is true even if spans/orphans are now empty:
+9 -4
View File
@@ -5,6 +5,7 @@ import { ThreadController } from "./threadController";
import { AttributionController } from "./attributionController";
import { ProposalController } from "./proposalController";
import { buildFingerprint } from "./anchorer";
import { VersionGuard } from "./versionGuard";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -12,6 +13,7 @@ export interface CowritingApi {
threadController: ThreadController;
attributionController: AttributionController;
proposalController: ProposalController;
versionGuard: VersionGuard;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -67,15 +69,18 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
return undefined;
}
const store = new CoauthorStore(root);
const threadController = new ThreadController(store, root);
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
// warning per doc across the three co-owning controllers.
const versionGuard = new VersionGuard(store);
const threadController = new ThreadController(store, root, versionGuard);
context.subscriptions.push(threadController);
// --- F3: live attribution (Feature #6) ---
const attributionController = new AttributionController(store, root);
const attributionController = new AttributionController(store, root, versionGuard);
context.subscriptions.push(attributionController);
// --- F4: propose/accept (Feature #12) ---
const proposalController = new ProposalController(store, attributionController, root);
const proposalController = new ProposalController(store, attributionController, root, versionGuard);
context.subscriptions.push(proposalController);
// One SHARED sidecar watcher for both controllers; self-writes are
@@ -228,7 +233,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
vscode.workspace.textDocuments.forEach(renderIfOpen);
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
return { threadController, attributionController, proposalController };
return { threadController, attributionController, proposalController, versionGuard };
}
export function deactivate(): void {
+25
View File
@@ -0,0 +1,25 @@
/**
* Cross-rung identity (F5, spec §6.3 fork c): `email` is git's own join key
* forges already map email account. Resolved from the workspace git config,
* cached per root, FAIL-OPEN: absence just omits the optional field and the
* sidecar stays valid (spec §6.9). vscode-free, so it is unit-testable.
*/
import { execFileSync } from "node:child_process";
const cache = new Map<string, string | undefined>();
export function gitUserEmail(rootDir: string): string | undefined {
if (!cache.has(rootDir)) {
try {
const out = execFileSync("git", ["config", "user.email"], {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
cache.set(rootDir, out || undefined);
} catch {
cache.set(rootDir, undefined);
}
}
return cache.get(rootDir);
}
+138
View File
@@ -0,0 +1,138 @@
/**
* mergeArtifacts the contract's merge semantics (INV-17) as a pure, vscode-
* free reference implementation (contract §6). 2-way union-by-id; every
* same-id divergence is resolved by the documented deterministic rules AND
* surfaced in `conflicts` never silently guessed (INV-1's spirit at the
* artifact level). Refuses differing majors (INV-16: a rung never merges what
* it can't fully read). Not wired into any UI rungs and humans invoke it; a
* .gitattributes merge driver is a later convenience (spec §1.7).
*/
import type { Artifact, Message, Thread } from "./model";
export interface MergeResult {
merged: Artifact;
/** ids (or root keys) whose divergence was rule-resolved — surfaced, never silent. */
conflicts: string[];
}
type Rec = Record<string, unknown>;
/** Order-insensitive canonical form: all object keys sorted, no whitespace (contract §6). */
function stableStringify(v: unknown): string {
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
if (v !== null && typeof v === "object") {
const rec = v as Rec;
return `{${Object.keys(rec)
.sort()
.map((k) => `${JSON.stringify(k)}:${stableStringify(rec[k])}`)
.join(",")}}`;
}
return JSON.stringify(v);
}
const same = (a: unknown, b: unknown): boolean => stableStringify(a) === stableStringify(b);
/** Deterministic, symmetric tie-break: the lexicographically larger canonical form wins. */
function lexLarger<T>(a: T, b: T): T {
return stableStringify(a) >= stableStringify(b) ? a : b;
}
/**
* Union two record arrays by id: one-side-only records pass through, identical
* records dedupe, same-id divergence is handed to `resolveDivergent` (which
* owns both picking the winner and reporting the conflict). Ours-order first,
* theirs-only appended in theirs-order.
*/
function unionById<T extends { id: string }>(ours: T[], theirs: T[], resolveDivergent: (o: T, t: T) => T): T[] {
const theirsById = new Map(theirs.map((t) => [t.id, t]));
const ourIds = new Set(ours.map((o) => o.id));
const out: T[] = [];
for (const o of ours) {
const t = theirsById.get(o.id);
out.push(t === undefined || same(o, t) ? o : resolveDivergent(o, t));
}
for (const t of theirs) if (!ourIds.has(t.id)) out.push(t);
return out;
}
/**
* Same-id threads: the shell (every field but `messages`) and the messages
* merge independently (contract §6). Divergent shells tie-break and report the
* thread id; messages union by id ordered by (createdAt, id), per-message
* divergence tie-breaks and reports the message id. The forge reply-append
* case merges cleanly by construction (fresh uuids, disjoint additions).
*/
function mergeThread(o: Thread, t: Thread, conflicts: string[]): Thread {
const { messages: oMsgs, ...oShell } = o;
const { messages: tMsgs, ...tShell } = t;
let shell = oShell;
if (!same(oShell, tShell)) {
conflicts.push(o.id);
shell = lexLarger(oShell, tShell);
}
const messages: Message[] = unionById(oMsgs, tMsgs, (a, b) => {
conflicts.push(a.id);
return lexLarger(a, b);
}).sort((a, b) =>
a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
);
return { ...shell, messages } as Thread;
}
export function mergeArtifacts(ours: Artifact, theirs: Artifact): MergeResult {
if (ours.schemaVersion !== theirs.schemaVersion) {
throw new Error(
`cannot merge differing schemaVersion majors: ${ours.schemaVersion} vs ${theirs.schemaVersion} (INV-16)`,
);
}
const conflicts: string[] = [];
// anchors: union by key; divergent same key → tie-break, reported.
const anchors: Artifact["anchors"] = { ...theirs.anchors, ...ours.anchors };
for (const k of Object.keys(ours.anchors)) {
const t = theirs.anchors[k];
if (t !== undefined && !same(ours.anchors[k], t)) {
conflicts.push(k);
anchors[k] = lexLarger(ours.anchors[k], t);
}
}
const threads = unionById(ours.threads, theirs.threads, (o, t) => mergeThread(o, t, conflicts));
const attributions = unionById(ours.attributions, theirs.attributions, (o, t) => {
conflicts.push(o.id);
// newer updatedAt wins; equal updatedAt falls through to the tie-break.
if (o.updatedAt !== t.updatedAt) return o.updatedAt > t.updatedAt ? o : t;
return lexLarger(o, t);
});
const proposals = unionById(ours.proposals, theirs.proposals, (o, t) => {
conflicts.push(o.id);
return lexLarger(o, t);
});
// root: known sections merged above; document must agree; unknown root keys
// union with the same tie-break + report (contract §6).
const KNOWN_ROOT = new Set(["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"]);
let document = ours.document;
if (!same(ours.document, theirs.document)) {
conflicts.push("document");
document = lexLarger(ours.document, theirs.document);
}
const merged = { schemaVersion: ours.schemaVersion, document, anchors, threads, attributions, proposals } as Artifact &
Rec;
const oRec = ours as unknown as Rec;
const tRec = theirs as unknown as Rec;
for (const k of [...new Set([...Object.keys(oRec), ...Object.keys(tRec)])].sort()) {
if (KNOWN_ROOT.has(k)) continue;
const inO = k in oRec;
const inT = k in tRec;
if (inO && inT && !same(oRec[k], tRec[k])) {
conflicts.push(k);
merged[k] = lexLarger(oRec[k], tRec[k]);
} else {
merged[k] = inO ? oRec[k] : tRec[k];
}
}
return { merged, conflicts };
}
+109 -22
View File
@@ -25,10 +25,25 @@ export interface Anchor {
fingerprint: Fingerprint;
}
/** The reusable author field (spec §6.3). `agent` only present when kind=agent. */
/**
* 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 }
| { kind: "agent"; id: string; agent: { sdk: string; model: string; sessionId: string } };
| { 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;
@@ -101,15 +116,63 @@ export function emptyArtifact(docPath: string): Artifact {
};
}
/**
* 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> {
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 };
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);
}
/**
@@ -118,44 +181,63 @@ function serializeProvenance(p: Provenance): Record<string, unknown> {
* ends with a trailing newline.
*/
export function serializeArtifact(a: Artifact): string {
const canonical = {
const canonical = withUnknowns(
{
schemaVersion: a.schemaVersion,
document: { path: a.document.path },
document: withUnknowns({ path: a.document.path }, a.document),
anchors: Object.fromEntries(
Object.keys(a.anchors)
.sort()
.map((k) => [
k,
withUnknowns(
{
fingerprint: withUnknowns(
{
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,
},
a.anchors[k].fingerprint,
),
},
a.anchors[k],
),
]),
),
threads: a.threads.map((t) => ({
threads: a.threads.map((t) =>
withUnknowns(
{
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.map((at) => ({
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 } : {}),
})),
proposals: a.proposals.map((p) => ({
},
at,
),
),
proposals: a.proposals.map((p) =>
withUnknowns(
{
id: p.id,
anchorId: p.anchorId,
replacement: p.replacement,
@@ -163,7 +245,12 @@ export function serializeArtifact(a: Artifact): string {
createdAt: p.createdAt,
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
})),
};
},
p,
),
),
},
a,
);
return JSON.stringify(canonical, null, 2) + "\n";
}
+5
View File
@@ -15,6 +15,7 @@ import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Pro
import { resolve, shift, type OffsetRange } from "./anchorer";
import { addProposal, proposalBody, removeProposal } from "./proposalModel";
import type { AttributionController } from "./attributionController";
import type { VersionGuard } from "./versionGuard";
/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedProposal {
@@ -53,6 +54,7 @@ export class ProposalController implements vscode.Disposable {
private readonly store: CoauthorStore,
private readonly attribution: AttributionController,
private readonly rootDir: string,
private readonly guard: VersionGuard,
) {
// No commentingRangeProvider: humans never open proposal threads by hand —
// proposals are born of machine turns only (INV-12 keeps decisions human).
@@ -103,6 +105,7 @@ export class ProposalController implements vscode.Disposable {
opts?: { turnId?: string; instruction?: string },
): Promise<string | undefined> {
if (!this.isTracked(document)) return undefined;
if (this.guard.isReadOnly(this.docPathOf(document.uri))) return undefined;
const docPath = this.docPathOf(document.uri);
let proposalId: string | undefined;
this.store.update(docPath, (a) => {
@@ -137,6 +140,7 @@ export class ProposalController implements vscode.Disposable {
}
private async accept(state: DocState, proposal: Proposal): Promise<boolean> {
if (this.guard.isReadOnly(state.docPath)) return false;
const document = this.openDoc(state);
if (!document) return false;
// INV-11: the fingerprint-guard — exact re-resolve at decision time.
@@ -167,6 +171,7 @@ export class ProposalController implements vscode.Disposable {
}
private reject(state: DocState, proposal: Proposal): void {
if (this.guard.isReadOnly(state.docPath)) return;
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
const document = this.openDoc(state);
if (document) this.renderAll(document);
+8 -1
View File
@@ -8,7 +8,7 @@
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { emptyArtifact, serializeArtifact, type Artifact } from "./model";
import { SCHEMA_VERSION, emptyArtifact, isNewerMajor, serializeArtifact, type Artifact } from "./model";
export class CoauthorStore {
/**
@@ -67,6 +67,13 @@ export class CoauthorStore {
*/
update(docPath: string, mutate: (artifact: Artifact) => void): Artifact {
const artifact = this.load(docPath) ?? emptyArtifact(docPath);
if (isNewerMajor(artifact)) {
// INV-16 backstop: the controllers gate first (VersionGuard); this throw
// guarantees no missed write path can ever destroy a newer rung's data.
throw new Error(
`refusing to write ${docPath}: sidecar schemaVersion ${artifact.schemaVersion} > supported ${SCHEMA_VERSION} (INV-16)`,
);
}
mutate(artifact);
const referenced = new Set<string>([
...artifact.threads.map((t) => t.anchorId),
+13 -2
View File
@@ -11,7 +11,9 @@ import * as vscode from "vscode";
import { CoauthorStore } from "./store";
import { emptyArtifact, type Artifact, type Provenance } from "./model";
import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer";
import { gitUserEmail } from "./identity";
import { addThread, appendMessage, setStatus } from "./threadModel";
import type { VersionGuard } from "./versionGuard";
/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedThread {
@@ -38,7 +40,11 @@ export class ThreadController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocState>(); // keyed by docPath
constructor(private readonly store: CoauthorStore, private readonly rootDir: string) {
constructor(
private readonly store: CoauthorStore,
private readonly rootDir: string,
private readonly guard: VersionGuard,
) {
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
this.controller.commentingRangeProvider = {
provideCommentingRanges: (document) => {
@@ -72,7 +78,8 @@ export class ThreadController implements vscode.Disposable {
private currentAuthor(): Provenance {
const id = vscode.workspace.getConfiguration("git").get<string>("user.name") || process.env.USER || "human";
return { kind: "human", id };
const email = gitUserEmail(this.rootDir);
return { kind: "human", id, ...(email !== undefined ? { email } : {}) };
}
private persist(state: DocState): void {
@@ -106,6 +113,7 @@ export class ThreadController implements vscode.Disposable {
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
const editor = vscode.window.activeTextEditor;
if (!editor || editor.selection.isEmpty || !this.isInRoot(editor.document.uri)) return undefined;
if (this.guard.isReadOnly(this.docPathOf(editor.document.uri))) return undefined;
const document = editor.document;
const state = this.ensureState(document);
const offsets: OffsetRange = {
@@ -125,6 +133,7 @@ export class ThreadController implements vscode.Disposable {
const threadId = this.threadIdOf(r.thread);
const state = this.stateOfThread(r.thread);
if (!threadId || !state) return;
if (this.guard.isReadOnly(state.docPath)) return;
appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text });
this.persist(state);
this.refreshComments(r.thread, state, threadId);
@@ -134,6 +143,7 @@ export class ThreadController implements vscode.Disposable {
const threadId = this.threadIdOf(vsThread);
const state = this.stateOfThread(vsThread);
if (!threadId || !state) return;
if (this.guard.isReadOnly(state.docPath)) return;
setStatus(state.artifact, threadId, status);
this.persist(state);
vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
@@ -196,6 +206,7 @@ export class ThreadController implements vscode.Disposable {
private onDidSave(document: vscode.TextDocument): void {
const state = this.docs.get(this.docPathOf(document.uri));
if (!state || state.live.size === 0) return;
if (this.guard.isReadOnly(state.docPath)) return;
const text = document.getText();
let changed = false;
for (const thread of state.artifact.threads) {
+35
View File
@@ -0,0 +1,35 @@
/**
* VersionGuard INV-16's editor face (spec §6.2/§6.4, PUC-4): a sidecar whose
* schemaVersion major is newer than this extension understands is READ-ONLY.
* Controllers render best-effort but skip every store.update path; the user is
* warned ONCE per document. One shared instance (extension.ts) because the
* sidecar is co-owned by three controllers one warning, not three. The
* store's update() throw is the backstop; this gate is the UX.
*/
import * as vscode from "vscode";
import { SCHEMA_VERSION, isNewerMajor } from "./model";
import type { CoauthorStore } from "./store";
export class VersionGuard {
private readonly warned = new Set<string>();
constructor(private readonly store: CoauthorStore) {}
/** True → skip the write path (and warn once per doc). */
isReadOnly(docPath: string): boolean {
const artifact = this.store.load(docPath);
if (!artifact || !isNewerMajor(artifact)) return false;
if (!this.warned.has(docPath)) {
this.warned.add(docPath);
void vscode.window.showWarningMessage(
`Cowriting: ${docPath}'s coauthoring sidecar is schemaVersion ${artifact.schemaVersion} — newer than this extension understands (${SCHEMA_VERSION}). Showing what it can; writing nothing (INV-16). Upgrade the extension to edit the record.`,
);
}
return true;
}
/** Test-facing: did the newer-major warning fire for this doc? */
wasWarned(docPath: string): boolean {
return this.warned.has(docPath);
}
}
+70
View File
@@ -0,0 +1,70 @@
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 { execFileSync } from "node:child_process";
import { emptyArtifact, serializeArtifact, type Artifact } from "../src/model";
import { validateSidecar } from "./helpers/validateSidecar";
const SCRIPT = path.resolve(__dirname, "../scripts/crossrung-reply.mjs");
let dir: string;
let sidecar: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-crossrung-"));
sidecar = path.join(dir, "sample.md.json");
const a = emptyArtifact("docs/sample.md");
a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: "", lineHint: 0 } };
a.threads.push({
id: "t_1",
anchorId: "a_1",
status: "open",
messages: [{ id: "m_1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }],
});
// INV-15 must hold through a FOREIGN writer too
(a as unknown as Record<string, unknown>).futureSection = [{ id: "f_1" }];
fs.writeFileSync(sidecar, serializeArtifact(a), "utf8");
});
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
function run(...args: string[]): string {
return execFileSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8" });
}
describe("crossrung-reply stand-in (F5 SLICE-4, PUC-2)", () => {
it("appends a conforming reply, preserves unknowns, byte-identical to the reference serializer", () => {
run(sidecar, "t_1", "Reply from the forge", "--author-id", "gitea", "--author-email", "forge@example.org");
const raw = fs.readFileSync(sidecar, "utf8");
const parsed = JSON.parse(raw) as Artifact;
expect(parsed.threads[0].messages).toHaveLength(2);
const reply = parsed.threads[0].messages[1];
expect(reply.body).toBe("Reply from the forge");
expect(reply.author).toEqual({ kind: "human", id: "gitea", email: "forge@example.org" });
expect(reply.id).toMatch(/^m_/);
expect((parsed as unknown as Record<string, unknown>).futureSection).toEqual([{ id: "f_1" }]);
expect(validateSidecar(parsed).valid).toBe(true);
// the stand-in's independent serialization is byte-identical to the
// editor's (INV-2 across writers) — the contract-drift tripwire.
expect(serializeArtifact(parsed)).toBe(raw);
});
it("refuses an unknown thread id and leaves the file untouched", () => {
const before = fs.readFileSync(sidecar, "utf8");
expect(() => run(sidecar, "t_nope", "x")).toThrow();
expect(fs.readFileSync(sidecar, "utf8")).toBe(before);
});
it("refuses an invalid sidecar (validates BEFORE writing)", () => {
fs.writeFileSync(sidecar, '{"schemaVersion":1}\n', "utf8");
expect(() => run(sidecar, "t_1", "x")).toThrow();
});
it("refuses a newer-major sidecar (INV-16 holds for foreign writers too)", () => {
const v2 = JSON.parse(fs.readFileSync(sidecar, "utf8"));
v2.schemaVersion = 2;
fs.writeFileSync(sidecar, JSON.stringify(v2, null, 2) + "\n", "utf8");
const before = fs.readFileSync(sidecar, "utf8");
expect(() => run(sidecar, "t_1", "x")).toThrow();
expect(fs.readFileSync(sidecar, "utf8")).toBe(before);
});
});
@@ -0,0 +1,5 @@
# Crossrung doc
The portable record travels with ordinary git.
Nothing else here.
+3
View File
@@ -0,0 +1,3 @@
# Future doc
Anchored text from the future.
+129
View File
@@ -0,0 +1,129 @@
import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import { execFileSync } from "child_process";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
import type { Artifact } from "../../../src/model";
const WS = process.env.E2E_WORKSPACE!;
const PROJECT_ROOT = path.resolve(__dirname, "../../../..");
const STANDIN = path.join(PROJECT_ROOT, "scripts", "crossrung-reply.mjs");
function sidecarPath(docRel: string): string {
return path.join(WS, ".threads", `${docRel}.json`);
}
async function open(docRel: string): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, docRel));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
return doc;
}
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.versionGuard, "extension exports versionGuard (F5)");
return api;
}
// The extension host is Electron: run the stand-in with the same binary in
// plain-node mode — a forge's write is just a local process to git (fork e).
function runStandin(...args: string[]): void {
execFileSync(process.execPath, [STANDIN, ...args], {
encoding: "utf8",
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
});
}
function commentsOf(api: CowritingApi, docRel: string, threadId: string): number {
const docs = (
api.threadController as unknown as {
docs: Map<string, { vsThreads: Map<string, vscode.CommentThread> }>;
}
).docs;
const vsThread = docs.get(docRel)?.vsThreads.get(threadId);
return vsThread ? vsThread.comments.length : -1;
}
async function until(cond: () => boolean, ms = 5000): Promise<void> {
const t0 = Date.now();
while (!cond()) {
if (Date.now() - t0 > ms) throw new Error("timed out waiting for condition");
await new Promise((r) => setTimeout(r, 100));
}
}
suite("F5 cross-rung round-trip (host E2E)", () => {
test("stand-in reply lands in the editor's thread via external change (PUC-2)", async () => {
const DOC = "docs/crossrung.md";
const doc = await open(DOC);
const api = await getApi();
const target = "portable record";
const start = doc.getText().indexOf(target);
const editor = vscode.window.activeTextEditor!;
editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + target.length));
const threadId = await api.threadController.createThreadOnSelection("Editor note");
assert.ok(threadId, "thread created in the editor");
// the Gitea-rung stand-in appends a reply OUT OF EDITOR (spec fork e)
runStandin(sidecarPath(DOC), threadId!, "Reply from the forge", "--author-id", "gitea", "--author-email", "forge@example.org");
// watcher → external change → re-render with the new message (the F2 path)
await until(() => commentsOf(api, DOC, threadId!) === 2);
const rendered = api.threadController.getRendered(DOC);
const mine = rendered.find((r) => r.id === threadId)!;
assert.strictEqual(mine.orphaned, false, "anchor still resolves after the foreign write");
const anchorLine = doc.positionAt(doc.getText().indexOf(target)).line;
assert.strictEqual(mine.range.startLine, anchorLine, "rendered at the re-resolved anchor");
const art = JSON.parse(fs.readFileSync(sidecarPath(DOC), "utf8")) as Artifact;
const msgs = art.threads.find((t) => t.id === threadId)!.messages;
assert.strictEqual(msgs.length, 2);
assert.deepStrictEqual(msgs[1].author, { kind: "human", id: "gitea", email: "forge@example.org" });
});
test("newer-major sidecar: warning + read-only, file bytes never change (PUC-4)", async () => {
const DOC = "docs/v2.md";
const scPath = sidecarPath(DOC);
fs.mkdirSync(path.dirname(scPath), { recursive: true });
// a v2 sidecar: valid v1 sections plus future content this build can't know
const v2 = {
schemaVersion: 2,
document: { path: DOC },
anchors: {
a_1: { fingerprint: { text: "Anchored text", before: "", after: " from the future.", lineHint: 2 } },
},
threads: [
{
id: "t_1",
anchorId: "a_1",
status: "open",
messages: [{ id: "m_1", author: { kind: "human", id: "future" }, body: "from v2", createdAt: "2026-06-10T00:00:00.000Z" }],
},
],
attributions: [],
proposals: [],
v2OnlySection: { shiny: true },
};
fs.writeFileSync(scPath, JSON.stringify(v2, null, 2) + "\n", "utf8");
const before = fs.readFileSync(scPath, "utf8");
const doc = await open(DOC);
const api = await getApi();
api.threadController.renderAll(doc); // render best-effort: the v1 subset shows
const rendered = api.threadController.getRendered(DOC);
assert.strictEqual(rendered.length, 1, "renders what it understands");
// write gestures are refused + warned (PUC-4)
const target = "Anchored text";
const start = doc.getText().indexOf(target);
const editor = vscode.window.activeTextEditor!;
editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + target.length));
const created = await api.threadController.createThreadOnSelection("should not land");
assert.strictEqual(created, undefined, "createThread refused on newer major");
assert.strictEqual(api.versionGuard.wasWarned(DOC), true, "user warned once (PUC-4)");
// an edit + save must NOT rewrite the sidecar (INV-16)
await editor.edit((b) => b.insert(doc.positionAt(0), "edited "));
await doc.save();
await new Promise((r) => setTimeout(r, 500));
assert.strictEqual(fs.readFileSync(scPath, "utf8"), before, "sidecar bytes untouched (INV-16)");
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* Schema-conformance helper (F5 SLICE-1, PUC-5): every serialized artifact in
* the unit suite must validate against the published cross-rung schema
* contract drift fails CI (spec §7.4).
*/
import { readFileSync } from "node:fs";
import Ajv from "ajv/dist/2020";
import { serializeArtifact, type Artifact } from "../../src/model";
const schemaUrl = new URL("../../schemas/coauthoring-sidecar.schema.json", import.meta.url);
const schema = JSON.parse(readFileSync(schemaUrl, "utf8"));
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
export function validateSidecar(data: unknown): { valid: boolean; errors: string[] } {
const valid = validate(data) as boolean;
const errors = (validate.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message}`);
return { valid, errors };
}
export function expectValidSidecar(artifact: Artifact): void {
const { valid, errors } = validateSidecar(JSON.parse(serializeArtifact(artifact)));
if (!valid) throw new Error(`sidecar failed schema validation:\n${errors.join("\n")}`);
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { execFileSync } from "node:child_process";
import { gitUserEmail } from "../src/identity";
let repo: string;
beforeAll(() => {
repo = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-identity-"));
execFileSync("git", ["init"], { cwd: repo, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "rung@example.org"], { cwd: repo });
});
afterAll(() => fs.rmSync(repo, { recursive: true, force: true }));
describe("gitUserEmail (F5 SLICE-2)", () => {
it("reads the workspace git config user.email", () => {
expect(gitUserEmail(repo)).toBe("rung@example.org");
});
it("fails open (undefined) when git cannot run there", () => {
expect(gitUserEmail("/nonexistent-dir-xyz")).toBeUndefined();
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect } from "vitest";
import { emptyArtifact, type Artifact } from "../src/model";
import { mergeArtifacts } from "../src/mergeArtifacts";
import { expectValidSidecar } from "./helpers/validateSidecar";
const FP = { text: "alpha", before: "", after: "", lineHint: 0 };
function base(): Artifact {
const a = emptyArtifact("docs/x.md");
a.anchors["a_1"] = { fingerprint: FP };
a.threads.push({
id: "t_1",
anchorId: "a_1",
status: "open",
messages: [{ id: "m_1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }],
});
return a;
}
const clone = (a: Artifact): Artifact => JSON.parse(JSON.stringify(a));
describe("mergeArtifacts (F5 SLICE-3, INV-17)", () => {
it("reply-append vs disjoint edit unions cleanly, no conflicts (the forge case)", () => {
const ours = base();
ours.attributions.push({
id: "at_1",
anchorId: "a_1",
author: { kind: "human", id: "ben" },
createdAt: "2026-06-10T01:00:00.000Z",
updatedAt: "2026-06-10T01:00:00.000Z",
});
const theirs = base();
theirs.threads[0].messages.push({
id: "m_2",
author: { kind: "human", id: "forge", email: "forge@example.org" },
body: "reply",
createdAt: "2026-06-10T02:00:00.000Z",
});
const { merged, conflicts } = mergeArtifacts(ours, theirs);
expect(conflicts).toEqual([]);
expect(merged.threads[0].messages.map((m) => m.id)).toEqual(["m_1", "m_2"]);
expect(merged.attributions.map((a) => a.id)).toEqual(["at_1"]);
expectValidSidecar(merged);
});
it("both sides append to the same thread: messages interleave by createdAt, no conflicts", () => {
const ours = base();
ours.threads[0].messages.push({ id: "m_b", author: { kind: "human", id: "ben" }, body: "B", createdAt: "2026-06-10T03:00:00.000Z" });
const theirs = base();
theirs.threads[0].messages.push({ id: "m_a", author: { kind: "human", id: "forge" }, body: "A", createdAt: "2026-06-10T02:00:00.000Z" });
const { merged, conflicts } = mergeArtifacts(ours, theirs);
expect(conflicts).toEqual([]);
expect(merged.threads[0].messages.map((m) => m.id)).toEqual(["m_1", "m_a", "m_b"]);
});
it("same attribution id divergent: newer updatedAt wins and the id is reported", () => {
const rec = {
id: "at_1",
anchorId: "a_1",
author: { kind: "human", id: "ben" } as const,
createdAt: "2026-06-10T00:00:00.000Z",
updatedAt: "2026-06-10T00:00:00.000Z",
};
const ours = base();
ours.attributions.push({ ...rec, updatedAt: "2026-06-10T05:00:00.000Z" });
const theirs = base();
theirs.attributions.push({ ...rec, updatedAt: "2026-06-10T04:00:00.000Z", turnId: "turn-x" });
const { merged, conflicts } = mergeArtifacts(ours, theirs);
expect(conflicts).toEqual(["at_1"]);
expect(merged.attributions[0].updatedAt).toBe("2026-06-10T05:00:00.000Z");
expect(merged.attributions[0].turnId).toBeUndefined();
});
it("divergent same-id proposals: deterministic tie-break, id reported, symmetric result", () => {
const p = {
id: "p_1",
anchorId: "a_1",
replacement: "AAA",
author: { kind: "human", id: "ben" } as const,
createdAt: "2026-06-10T00:00:00.000Z",
};
const ours = base();
ours.proposals.push({ ...p });
const theirs = base();
theirs.proposals.push({ ...p, replacement: "ZZZ" });
const r1 = mergeArtifacts(ours, theirs);
const r2 = mergeArtifacts(theirs, ours);
expect(r1.conflicts).toEqual(["p_1"]);
expect(r1.merged.proposals[0].replacement).toBe("ZZZ");
expect(r2.merged.proposals[0].replacement).toBe("ZZZ");
});
it("thread status divergence resolves deterministically and reports the thread id", () => {
const ours = base();
const theirs = clone(ours);
theirs.threads[0].status = "resolved";
const { merged, conflicts } = mergeArtifacts(ours, theirs);
expect(merged.threads[0].status).toBe("resolved");
expect(conflicts).toEqual(["t_1"]);
});
it("divergent same-id messages tie-break symmetrically and report the message id", () => {
const ours = base();
const theirs = clone(ours);
theirs.threads[0].messages[0].body = "hi (edited)";
const r1 = mergeArtifacts(ours, theirs);
const r2 = mergeArtifacts(theirs, ours);
expect(r1.conflicts).toEqual(["m_1"]);
expect(r2.conflicts).toEqual(["m_1"]);
// deterministic + symmetric: both directions pick the same winner
expect(r1.merged.threads[0].messages[0].body).toBe(r2.merged.threads[0].messages[0].body);
});
it("anchors union by key; divergent same key reported", () => {
const ours = base();
const theirs = base();
theirs.anchors["a_2"] = { fingerprint: { ...FP, text: "beta" } };
const clean = mergeArtifacts(ours, theirs);
expect(Object.keys(clean.merged.anchors).sort()).toEqual(["a_1", "a_2"]);
expect(clean.conflicts).toEqual([]);
const theirs2 = clone(ours);
theirs2.anchors["a_1"] = { fingerprint: { ...FP, lineHint: 9 } };
const dirty = mergeArtifacts(ours, theirs2);
expect(dirty.conflicts).toEqual(["a_1"]);
});
it("unknown fields ride with the winning record and root unknowns union", () => {
const ours = base() as Artifact & Record<string, unknown>;
ours.futureSection = [1];
const theirs = base() as Artifact & Record<string, unknown>;
theirs.otherFuture = "x";
const { merged, conflicts } = mergeArtifacts(ours, theirs);
expect((merged as unknown as Record<string, unknown>).futureSection).toEqual([1]);
expect((merged as unknown as Record<string, unknown>).otherFuture).toBe("x");
expect(conflicts).toEqual([]);
});
it("throws on differing majors (INV-16)", () => {
const ours = base();
const theirs = { ...base(), schemaVersion: 2 };
expect(() => mergeArtifacts(ours, theirs)).toThrow(/schemaVersion/);
});
});
+122
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
SCHEMA_VERSION,
emptyArtifact,
isNewerMajor,
serializeArtifact,
type Artifact,
} from "../src/model";
@@ -57,6 +58,127 @@ describe("serializeArtifact", () => {
});
});
describe("cross-rung identity (F5 SLICE-2)", () => {
it("round-trips email on human provenance, omitted when absent", () => {
const a = emptyArtifact("docs/x.md");
a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: "", lineHint: 0 } };
a.threads.push({
id: "t_1",
anchorId: "a_1",
status: "open",
messages: [
{ id: "m_1", author: { kind: "human", id: "ben", email: "ben@wiggleverse.org" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" },
{ id: "m_2", author: { kind: "human", id: "anon" }, body: "yo", createdAt: "2026-06-10T00:00:01.000Z" },
],
});
const out = serializeArtifact(a);
const parsed = JSON.parse(out) as Artifact;
expect(parsed.threads[0].messages[0].author).toEqual({ kind: "human", id: "ben", email: "ben@wiggleverse.org" });
expect("email" in parsed.threads[0].messages[1].author).toBe(false);
expect(serializeArtifact(parsed)).toBe(out);
});
it("round-trips agent.onBehalfOf and omits it when absent", () => {
const a = emptyArtifact("docs/x.md");
a.attributions.push({
id: "at_1",
anchorId: "a_1",
author: {
kind: "agent",
id: "claude",
agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1", onBehalfOf: { id: "benstull", email: "ben@wiggleverse.org" } },
},
createdAt: "2026-06-10T00:00:00.000Z",
updatedAt: "2026-06-10T00:00:00.000Z",
});
const out = serializeArtifact(a);
const parsed = JSON.parse(out) as Artifact;
const author = parsed.attributions[0].author;
expect(author.kind).toBe("agent");
if (author.kind === "agent") {
expect(author.agent.onBehalfOf).toEqual({ id: "benstull", email: "ben@wiggleverse.org" });
}
expect(out.includes("onBehalfOf")).toBe(true);
const a2 = emptyArtifact("docs/x.md");
expect(serializeArtifact(a2).includes("onBehalfOf")).toBe(false);
});
});
describe("isNewerMajor (F5 SLICE-2, INV-16)", () => {
it("is false for v1 and true for a newer major", () => {
expect(isNewerMajor(emptyArtifact("d.md"))).toBe(false);
expect(isNewerMajor({ schemaVersion: 2 })).toBe(true);
});
});
describe("unknown-field preservation (F5 SLICE-2, INV-15)", () => {
it("a rewrite preserves unknown fields at every level, after known keys, sorted", () => {
const foreign = {
schemaVersion: 1,
document: { path: "docs/x.md", zFuture: "keep-me" },
anchors: {
a_1: { fingerprint: { text: "alpha", before: "", after: "", lineHint: 0, confidence: 0.9 }, label: "intro" },
},
threads: [
{
id: "t_1",
anchorId: "a_1",
status: "open",
messages: [
{
id: "m_1",
author: { kind: "human", id: "ben", forgeLogin: "bstull" },
body: "hi",
createdAt: "2026-06-10T00:00:00.000Z",
reactions: ["+1"],
},
],
locked: false,
},
],
attributions: [
{
id: "at_1",
anchorId: "a_1",
author: { kind: "human", id: "ben" },
createdAt: "2026-06-10T00:00:00.000Z",
updatedAt: "2026-06-10T00:00:00.000Z",
reviewState: "approved",
},
],
proposals: [
{
id: "p_1",
anchorId: "a_1",
replacement: "ALPHA",
author: { kind: "human", id: "ben" },
createdAt: "2026-06-10T00:00:00.000Z",
priority: 2,
},
],
futureSection: [{ id: "f_1" }],
aaaEarly: true,
};
const out = serializeArtifact(foreign as unknown as Artifact);
const parsed = JSON.parse(out);
expect(parsed.futureSection).toEqual([{ id: "f_1" }]);
expect(parsed.aaaEarly).toBe(true);
expect(parsed.document.zFuture).toBe("keep-me");
expect(parsed.anchors.a_1.label).toBe("intro");
expect(parsed.anchors.a_1.fingerprint.confidence).toBe(0.9);
expect(parsed.threads[0].locked).toBe(false);
expect(parsed.threads[0].messages[0].reactions).toEqual(["+1"]);
expect(parsed.threads[0].messages[0].author.forgeLogin).toBe("bstull");
expect(parsed.attributions[0].reviewState).toBe("approved");
expect(parsed.proposals[0].priority).toBe(2);
// unknown ROOT keys serialize AFTER known keys, sorted: aaaEarly then futureSection, both after proposals
expect(out.indexOf('"proposals"')).toBeLessThan(out.indexOf('"aaaEarly"'));
expect(out.indexOf('"aaaEarly"')).toBeLessThan(out.indexOf('"futureSection"'));
// byte-stable on re-serialize (INV-2 holds with unknowns present)
expect(serializeArtifact(parsed as Artifact)).toBe(out);
});
});
describe("attributions[] (F3 SLICE-1)", () => {
it("round-trips an attribution record through serialize → parse", () => {
const a = emptyArtifact("docs/x.md");
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { emptyArtifact, serializeArtifact, type Artifact } from "../src/model";
import { expectValidSidecar, validateSidecar } from "./helpers/validateSidecar";
function fullArtifact(): Artifact {
const a = emptyArtifact("docs/x.md");
a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: " beta", lineHint: 0 } };
a.threads.push({
id: "t_1",
anchorId: "a_1",
status: "open",
messages: [{ id: "m_1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }],
});
a.attributions.push({
id: "at_1",
anchorId: "a_1",
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" } },
createdAt: "2026-06-10T00:00:00.000Z",
updatedAt: "2026-06-10T00:00:00.000Z",
turnId: "turn-1",
});
a.proposals.push({
id: "p_1",
anchorId: "a_1",
replacement: "ALPHA",
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" } },
createdAt: "2026-06-10T00:00:00.000Z",
instruction: "shout",
});
return a;
}
describe("coauthoring-sidecar JSON Schema (F5 SLICE-1, PUC-5)", () => {
it("validates the empty artifact and a full artifact", () => {
expectValidSidecar(emptyArtifact("docs/x.md"));
expectValidSidecar(fullArtifact());
});
it("rejects a structurally broken sidecar with error paths", () => {
const broken = JSON.parse(serializeArtifact(fullArtifact()));
broken.threads[0].status = "closed"; // not in the enum
delete broken.schemaVersion;
const { valid, errors } = validateSidecar(broken);
expect(valid).toBe(false);
expect(errors.some((e) => e.includes("schemaVersion"))).toBe(true);
});
it("accepts unknown fields everywhere (forward compat, INV-16)", () => {
const data = JSON.parse(serializeArtifact(fullArtifact()));
data.futureSection = [{ id: "f_1" }];
data.threads[0].futureField = true;
data.threads[0].messages[0].reactions = ["+1"];
const { valid } = validateSidecar(data);
expect(valid).toBe(true);
});
});
+17
View File
@@ -107,3 +107,20 @@ describe("CoauthorStore", () => {
expect(store.consumeSelfWrite(p)).toBe(false);
});
});
describe("newer-major write refusal (F5 SLICE-2, INV-16 backstop)", () => {
it("update REFUSES to write a sidecar from a newer major, leaving bytes untouched", () => {
const store = new CoauthorStore(root);
const p = store.sidecarPath("docs/v2.md");
fs.mkdirSync(path.dirname(p), { recursive: true });
const v2 = { ...emptyArtifact("docs/v2.md"), schemaVersion: 2 };
fs.writeFileSync(p, serializeArtifact(v2), "utf8");
const before = fs.readFileSync(p, "utf8");
expect(() =>
store.update("docs/v2.md", (a) => {
a.threads = [];
}),
).toThrow(/INV-16/);
expect(fs.readFileSync(p, "utf8")).toBe(before);
});
});