Files
vscode-cowriting-plugin-con…/plans/2026-06-10-f5-cross-rung-format.md
T

1542 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# F5 Cross-Rung Format + Round-Trip (Feature #14) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Graduate the `.threads/` sidecar into the published cross-rung contract: contract doc + JSON Schema + validator, identity extension (`email`, `agent.onBehalfOf`), unknown-field preservation (INV-15), newer-major fail-safe (INV-16), deterministic `mergeArtifacts` (INV-17), and a proven out-of-editor round-trip via a Gitea-rung stand-in writer.
**Architecture:** All changes are additive (`schemaVersion` stays 1). Two artifacts (contract prose in the content repo; JSON Schema + validator in the plugin repo), two vscode-free modules (`mergeArtifacts`, `identity`), serializer-level unknown-field preservation in `model.ts`, a shared `VersionGuard` gating every `store.update` path across the three controllers, and a self-contained stand-in writer script exercised by unit + host E2E tests. Spec: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`.
**Tech Stack:** TypeScript (strict), vitest (unit), `@vscode/test-electron` + mocha (host E2E), ajv (JSON Schema 2020-12, devDependency only), esbuild bundle untouched (no runtime deps added).
---
### Task 1: Branch + ajv devDependency
**Files:**
- Modify: `package.json` (devDependencies)
- [ ] **Step 1: Create the feature branch**
```bash
git checkout -b feature/14-cross-rung-format
```
- [ ] **Step 2: Install ajv as a devDependency**
```bash
npm install --save-dev ajv
```
Expected: `ajv` ^8.x in devDependencies; bundle externals unchanged (ajv is dev/scripts-only, spec §6.6).
- [ ] **Step 3: Commit**
```bash
git add package.json package-lock.json
git commit -m "chore: add ajv devDependency for sidecar schema validation (F5 SLICE-1, #14)"
```
### Task 2: JSON Schema + validator script + schema unit suite (SLICE-1, PUC-5)
**Files:**
- Create: `schemas/coauthoring-sidecar.schema.json`
- Create: `scripts/validate-sidecar.mjs`
- Create: `test/helpers/validateSidecar.ts`
- Create: `test/schema.test.ts`
- [ ] **Step 1: Write the failing schema test**
`test/schema.test.ts`:
```ts
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 (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);
});
});
```
`test/helpers/validateSidecar.ts`:
```ts
/**
* 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")}`);
}
```
- [ ] **Step 2: Run to verify failure**
Run: `npx vitest run test/schema.test.ts`
Expected: FAIL (schema file missing).
- [ ] **Step 3: Write the schema**
`schemas/coauthoring-sidecar.schema.json` — JSON Schema 2020-12 for the v1 artifact. Known fields validated strictly; unknown fields allowed everywhere (no `additionalProperties: false` — INV-15/16):
```json
{
"$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 specs/coauthoring-sidecar-contract.md (the prose contract is normative, INV-14). Unknown fields are deliberately allowed at every level: minors 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" }
}
}
}
}
```
- [ ] **Step 4: Run to verify pass**
Run: `npx vitest run test/schema.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 5: Write the standalone validator script**
`scripts/validate-sidecar.mjs`:
```js
#!/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);
```
- [ ] **Step 6: Scripted check of the validator (good + broken)**
```bash
node -e 'const {emptyArtifact,serializeArtifact}=await import("./src/model.ts").catch(()=>({}));' 2>/dev/null || true
```
Simpler — use a committed test run via temp files:
```bash
printf '{"schemaVersion":1,"document":{"path":"d.md"},"anchors":{},"threads":[],"attributions":[],"proposals":[]}\n' > /tmp/good-sidecar.json
node scripts/validate-sidecar.mjs /tmp/good-sidecar.json
printf '{"document":{"path":"d.md"}}\n' > /tmp/bad-sidecar.json
node scripts/validate-sidecar.mjs /tmp/bad-sidecar.json; echo "exit=$?"
```
Expected: first prints `OK … (schemaVersion 1)`; second prints errors and `exit=1`.
- [ ] **Step 7: Commit**
```bash
git add schemas/ scripts/validate-sidecar.mjs test/helpers/validateSidecar.ts test/schema.test.ts
git commit -m "feat: publish coauthoring-sidecar JSON Schema + rung-neutral validator (F5 SLICE-1, PUC-5, #14)"
```
### Task 3: The contract document (SLICE-1, INV-14) — content repo
**Files:**
- Create: `/Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-content/specs/coauthoring-sidecar-contract.md`
- [ ] **Step 1: Write the contract** — normative prose, status-stamped (`status: living` front-matter like the specs), versioned **v1.0** with a changelog. Required sections (content drawn from spec §6.3/§6.4 + the shipped F2/F3/F4 §6.3 field tables, which it supersedes as format reference):
1. **Scope & status** — what the sidecar is (`.threads/<repo-relative-doc-path>.json`, per-document, git-carried); this doc is canonical (INV-14): format changes land here first, then schema, then code; the JSON Schema (`vscode-cowriting-plugin/schemas/coauthoring-sidecar.schema.json`) is the machine-checkable half.
2. **Serialization rules (INV-2)** — pretty JSON, 2-space indent, trailing newline; root key order `schemaVersion, document, anchors, threads, attributions, proposals`; anchors sorted by key; per-record known-key order as the reference serializer emits; unknown keys after known keys, sorted lexicographically.
3. **Field tables** — one table per section: root, `document`, `anchors`/`Anchor`/`Fingerprint` (INV-3 semantics: text is truth, lineHint a tie-breaker), `Provenance` (incl. new `email`, `agent.onBehalfOf {id, email?}` — git's identity model, email is the cross-rung join key; PII note per spec §6.6), `Thread`/`Message`, `AttributionRecord` (state-not-history), `Proposal` (pending-only, INV-13).
4. **Compatibility (INV-15/INV-16)** — within a major: additive-only; readers ignore unknown optionals; **writers preserve unknown fields at every object level**; newer major ⇒ fail-safe read-only (render what you understand, write nothing). Breaking changes bump the major and are recorded in the changelog.
5. **Merge rules (INV-17)** — 2-way `merge(ours, theirs)`: refuse differing majors; anchors union by key; `threads`/`attributions`/`proposals` union by `id`; same-id thread: messages union by `id` ordered by (`createdAt`, `id`), shell (non-`messages` fields) divergence resolved by lexicographically-larger stable-stringify; attribution divergence: newer `updatedAt` wins, ties by stable-stringify; message/proposal/anchor divergence: lexicographically-larger stable-stringify; root unknown-key value divergence: lexicographically-larger; **every rule-resolved divergence is surfaced to the caller** (never silent). Stable-stringify = JSON with all object keys sorted, no whitespace. Reference implementation: `vscode-cowriting-plugin/src/mergeArtifacts.ts`.
6. **Conforming-writer checklist** — validate before and after; preserve unknowns; never write a newer major; fresh uuids for new records; ISO-8601 UTC timestamps. Stand-in example: `scripts/crossrung-reply.mjs`.
7. **Changelog**`v1.0 (2026-06-10)` initial publication, ratifying the shipped F2F4 format + the F5 additive identity fields.
- [ ] **Step 2: Commit + push (content repo, direct to main — established content-repo pattern)**
```bash
git -C /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-content add specs/coauthoring-sidecar-contract.md
git -C /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-content commit -m "add specs/coauthoring-sidecar-contract.md (cross-rung contract v1.0, F5 #14)"
git -C /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-content push
```
### Task 4: Provenance identity extension (SLICE-2, fork c)
**Files:**
- Modify: `src/model.ts` (Provenance type + serializeProvenance)
- Test: `test/model.test.ts`
- [ ] **Step 1: Write the failing tests** (append to `test/model.test.ts`):
```ts
describe("cross-rung identity (F5 SLICE-2)", () => {
it("round-trips email on human and agent 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);
});
});
```
- [ ] **Step 2: Run to verify failure**`npx vitest run test/model.test.ts` → FAIL (type errors: `email` not on Provenance).
- [ ] **Step 3: Implement.** In `src/model.ts` replace the `Provenance` type and `serializeProvenance`:
```ts
/**
* The reusable author field (contract §Provenance). `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 };
};
};
```
```ts
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 = {
sdk: p.agent.sdk,
model: p.agent.model,
sessionId: p.agent.sessionId,
...(p.agent.onBehalfOf !== undefined
? {
onBehalfOf: {
id: p.agent.onBehalfOf.id,
...(p.agent.onBehalfOf.email !== undefined ? { email: p.agent.onBehalfOf.email } : {}),
},
}
: {}),
};
}
return known;
}
```
(Unknown-field preservation for provenance comes in Task 5 — keep this step minimal.)
- [ ] **Step 4: Run to verify pass**`npx vitest run test/model.test.ts && npm run typecheck` → PASS.
- [ ] **Step 5: Commit**
```bash
git add src/model.ts test/model.test.ts
git commit -m "feat: Provenance gains email + agent.onBehalfOf — cross-rung identity (F5 SLICE-2, #14)"
```
### Task 5: Unknown-field preservation in the serializer (SLICE-2, INV-15)
**Files:**
- Modify: `src/model.ts` (serializeArtifact + helpers)
- Test: `test/model.test.ts`
- [ ] **Step 1: Write the failing tests** (append to `test/model.test.ts`):
```ts
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);
});
});
```
- [ ] **Step 2: Run to verify failure**`npx vitest run test/model.test.ts` → FAIL (unknowns dropped today).
- [ ] **Step 3: Implement.** In `src/model.ts` add a helper and thread it through every object the serializer rebuilds (root, document, anchor, fingerprint, thread, message, attribution, proposal, provenance, agent payload, onBehalfOf):
```ts
/**
* 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).
*/
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;
}
```
Then rewrite `serializeProvenance` to wrap each constructed object: `withUnknowns(known, p)`, agent payload `withUnknowns(agentKnown, p.agent)`, onBehalfOf `withUnknowns(oboKnown, p.agent.onBehalfOf)`. And rewrite `serializeArtifact` so every nested literal goes through `withUnknowns(..., sourceObject)` — e.g.:
```ts
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";
}
```
Gotcha: `withUnknowns` uses `!(k in known)` — an omitted optional (e.g. `turnId` undefined) is not in `known`, so a parsed record that HAS `turnId` keeps it via the spread above (it's a known key when present). Correct by construction. Update the schema-suite + add `expectValidSidecar` calls where natural.
- [ ] **Step 4: Run full unit suite**`npx vitest run && npm run typecheck` → PASS (existing byte-stability tests prove no regression for clean v1 sidecars).
- [ ] **Step 5: Commit**
```bash
git add src/model.ts test/model.test.ts
git commit -m "feat: serializer preserves unknown fields at every level (F5 SLICE-2, INV-15, #14)"
```
### Task 6: isNewerMajor + store write-refusal backstop (SLICE-2, INV-16)
**Files:**
- Modify: `src/model.ts` (isNewerMajor), `src/store.ts` (update refusal)
- Test: `test/model.test.ts`, `test/store.test.ts`
- [ ] **Step 1: Write the failing tests.** Append to `test/model.test.ts`:
```ts
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);
});
});
```
Append to `test/store.test.ts` (match its existing tmpdir pattern):
```ts
it("update REFUSES to write a sidecar from a newer major (INV-16 backstop)", () => {
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);
});
```
- [ ] **Step 2: Run to verify failure**`npx vitest run test/model.test.ts test/store.test.ts` → FAIL.
- [ ] **Step 3: Implement.** `src/model.ts`:
```ts
/**
* 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;
}
```
`src/store.ts` `update()` — after the fresh load, before `mutate`:
```ts
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)`,
);
}
```
(import `isNewerMajor`, `SCHEMA_VERSION` from `./model`.)
- [ ] **Step 4: Run to verify pass**`npx vitest run && npm run typecheck` → PASS.
- [ ] **Step 5: Commit**
```bash
git add src/model.ts src/store.ts test/model.test.ts test/store.test.ts
git commit -m "feat: isNewerMajor + store write-refusal backstop (F5 SLICE-2, INV-16, #14)"
```
### Task 7: gitUserEmail + currentAuthor() email population (SLICE-2)
**Files:**
- Create: `src/identity.ts`
- Modify: `src/threadController.ts:73-76`, `src/attributionController.ts:80-83`
- Test: `test/identity.test.ts`
- [ ] **Step 1: Write the failing test**`test/identity.test.ts`:
```ts
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 });
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) outside any usable config", () => {
// GIT_CONFIG_* env isolation makes even global config invisible
expect(gitUserEmail("/nonexistent-dir-xyz")).toBeUndefined();
});
});
```
- [ ] **Step 2: Run to verify failure**`npx vitest run test/identity.test.ts` → FAIL (module missing).
- [ ] **Step 3: Implement** `src/identity.ts` (vscode-free):
```ts
/**
* 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).
*/
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);
}
```
- [ ] **Step 4: Wire into both controllers.** Identical change in `src/threadController.ts` and `src/attributionController.ts` (`import { gitUserEmail } from "./identity";`):
```ts
private currentAuthor(): Provenance {
const id = vscode.workspace.getConfiguration("git").get<string>("user.name") || process.env.USER || "human";
const email = gitUserEmail(this.rootDir);
return { kind: "human", id, ...(email !== undefined ? { email } : {}) };
}
```
(ThreadController already has `rootDir` as a constructor field; AttributionController too.)
- [ ] **Step 5: Run**`npx vitest run && npm run typecheck` → PASS.
- [ ] **Step 6: Commit**
```bash
git add src/identity.ts src/threadController.ts src/attributionController.ts test/identity.test.ts
git commit -m "feat: currentAuthor() carries git user.email — fail-open identity (F5 SLICE-2, #14)"
```
### Task 8: VersionGuard — controllers go read-only on newer major (SLICE-2, PUC-4)
**Files:**
- Create: `src/versionGuard.ts`
- Modify: `src/threadController.ts` (ctor + createThreadOnSelection/reply/setThreadStatus/onDidSave), `src/attributionController.ts` (ctor + onDidSave), `src/proposalController.ts` (ctor + propose/accept/reject), `src/extension.ts` (wiring + CowritingApi)
(Host-E2E coverage lands in Task 11; this task is wiring + typecheck + existing suites stay green.)
- [ ] **Step 1: Implement** `src/versionGuard.ts`:
```ts
/**
* 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.
*/
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);
}
}
```
- [ ] **Step 2: Gate ThreadController.** Constructor becomes `constructor(private readonly store: CoauthorStore, private readonly rootDir: string, private readonly guard: VersionGuard)`. Add an early return to each write gesture:
- `createThreadOnSelection`: after the `isInRoot` check — `if (this.guard.isReadOnly(this.docPathOf(editor.document.uri))) return undefined;`
- `reply` and `setThreadStatus`: after resolving `state``if (this.guard.isReadOnly(state.docPath)) return;`
- `onDidSave`: after the `!state || state.live.size === 0` check — `if (this.guard.isReadOnly(state.docPath)) return;`
- [ ] **Step 3: Gate AttributionController.** Constructor gains the same third param. In `onDidSave`, after `if (!this.isTracked(document)) return;``if (this.guard.isReadOnly(this.docPathOf(document.uri))) return;`
- [ ] **Step 4: Gate ProposalController.** Constructor becomes `(store, attribution, rootDir, guard)`. In `propose`, after `isTracked``if (this.guard.isReadOnly(this.docPathOf(document.uri))) return undefined;`. In `accept` and `reject`, first line — `if (this.guard.isReadOnly(state.docPath)) return false;` (reject's signature is void-ish: just `return;`).
- [ ] **Step 5: Wire extension.ts.** After `const store = new CoauthorStore(root);`:
```ts
const versionGuard = new VersionGuard(store);
const threadController = new ThreadController(store, root, versionGuard);
// …
const attributionController = new AttributionController(store, root, versionGuard);
// …
const proposalController = new ProposalController(store, attributionController, root, versionGuard);
```
Extend the API type + return:
```ts
export interface CowritingApi {
threadController: ThreadController;
attributionController: AttributionController;
proposalController: ProposalController;
versionGuard: VersionGuard;
}
// …
return { threadController, attributionController, proposalController, versionGuard };
```
- [ ] **Step 6: Verify**`npm run typecheck && npx vitest run && npm run build` → PASS.
- [ ] **Step 7: Commit**
```bash
git add src/versionGuard.ts src/threadController.ts src/attributionController.ts src/proposalController.ts src/extension.ts
git commit -m "feat: VersionGuard — newer-major sidecars are read-only with one warning (F5 SLICE-2, INV-16/PUC-4, #14)"
```
### Task 9: mergeArtifacts (SLICE-3, INV-17)
**Files:**
- Create: `src/mergeArtifacts.ts`
- Test: `test/mergeArtifacts.test.ts`
- [ ] **Step 1: Write the failing tests**`test/mergeArtifacts.test.ts`, covering every documented case (spec §6.5 PUC-3):
```ts
import { describe, it, expect } from "vitest";
import { emptyArtifact, type Artifact, type Thread } 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("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/);
});
});
```
- [ ] **Step 2: Run to verify failure**`npx vitest run test/mergeArtifacts.test.ts` → FAIL (module missing).
- [ ] **Step 3: Implement** `src/mergeArtifacts.ts` (pure, vscode-free — the contract §Merge's reference implementation):
```ts
/**
* mergeArtifacts — the contract's merge semantics (INV-17) as a pure, vscode-
* free reference implementation. 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: never merge what you 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. */
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 tie-break: the lexicographically larger canonical form wins. */
function lexLarger<T>(a: T, b: T): T {
return stableStringify(a) >= stableStringify(b) ? a : b;
}
function unionById<T extends { id: string }>(
ours: T[],
theirs: T[],
resolveDivergent: (o: T, t: T) => T,
conflicts: string[],
): 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);
if (t === undefined || same(o, t)) {
out.push(o);
} else {
conflicts.push(o.id);
out.push(resolveDivergent(o, t));
}
}
for (const t of theirs) if (!ourIds.has(t.id)) out.push(t);
return out;
}
/** Same-id threads: shells resolve deterministically; messages union and interleave. */
function mergeThread(o: Thread, t: Thread, conflicts: string[]): Thread {
const { messages: oMsgs, ...oShell } = o;
const { messages: tMsgs, ...tShell } = t;
if (!same(oShell, tShell)) conflicts.push(o.id);
const shell = same(oShell, tShell) ? oShell : lexLarger(oShell, tShell);
const msgConflicts: string[] = [];
const messages = unionById(oMsgs, tMsgs, (a, b) => lexLarger(a, b), msgConflicts).sort(
(a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : 1),
);
conflicts.push(...msgConflicts);
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 → deterministic 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 reports its own conflicts (shell and per-message).
const before = conflicts.length;
const merged = mergeThread(o, t, conflicts);
if (conflicts.length === before) conflicts.push(o.id);
conflicts.splice(before, conflicts.length - before, ...new Set(conflicts.slice(before)));
return merged;
}, []);
const attributions = unionById(
ours.attributions,
theirs.attributions,
(o, t) => (o.updatedAt === t.updatedAt ? lexLarger(o, t) : o.updatedAt > t.updatedAt ? o : t),
conflicts,
);
const proposals = unionById(ours.proposals, theirs.proposals, (o, t) => lexLarger(o, t), conflicts);
// root: known sections merged above; unknown root keys union, divergence reported.
const KNOWN_ROOT = new Set(["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"]);
const merged = {
schemaVersion: ours.schemaVersion,
document: same(ours.document, theirs.document)
? ours.document
: (conflicts.push("document"), lexLarger(ours.document, theirs.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, 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 };
}
```
NOTE for the engineer: the `threads` unionById call above passes `[]` as its conflicts sink and handles reporting inside the resolver via the outer `conflicts` — when implementing, SIMPLIFY this: make `unionById` take the resolver only and let the THREAD resolver push to the outer `conflicts` itself (a divergent thread reports its own id once via `mergeThread`; the dedupe splice is defensive noise — drop it if `mergeThread` already pushes `o.id` exactly once when the shell diverges, and per-message ids when messages diverge). The tests define the required behavior: divergent shell → thread id reported once; divergent same-id messages → message ids reported; clean interleave → no conflicts.
- [ ] **Step 4: Run to verify pass**`npx vitest run test/mergeArtifacts.test.ts && npm run typecheck` → PASS.
- [ ] **Step 5: Commit**
```bash
git add src/mergeArtifacts.ts test/mergeArtifacts.test.ts
git commit -m "feat: mergeArtifacts — deterministic union-by-id with surfaced conflicts (F5 SLICE-3, INV-17, #14)"
```
### Task 10: The Gitea-rung stand-in writer (SLICE-4, fork e)
**Files:**
- Create: `scripts/crossrung-reply.mjs`
- Test: `test/crossrungReply.test.ts`
- [ ] **Step 1: Write the failing test**`test/crossrungReply.test.ts` (spawns the script with the repo's node, asserts append + validity + BYTE-FIDELITY against the reference serializer — the drift tripwire):
```ts
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" }],
});
(a as unknown as Record<string, unknown>).futureSection = [{ id: "f_1" }]; // INV-15 through a foreign writer
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 serialization is byte-identical to the editor's (INV-2 across writers)
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();
});
});
```
- [ ] **Step 2: Run to verify failure**`npx vitest run test/crossrungReply.test.ts` → FAIL.
- [ ] **Step 3: Implement** `scripts/crossrung-reply.mjs` — a SELF-CONTAINED foreign writer (it deliberately re-implements the contract's serialization rather than importing the extension's serializer: it IS the executable documentation of how a second rung behaves; the byte-fidelity unit test above is the anti-drift tripwire):
```js
#!/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 specs/coauthoring-sidecar-contract.md:
// validate → 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.
//
// 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, INV-2 + INV-15) ---
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 ---
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}`);
```
- [ ] **Step 4: Run to verify pass**`npx vitest run test/crossrungReply.test.ts` → PASS. The byte-fidelity assertion is the load-bearing one: if the stand-in's `serialize` ever disagrees with `serializeArtifact`, this fails.
- [ ] **Step 5: Commit**
```bash
git add scripts/crossrung-reply.mjs test/crossrungReply.test.ts
git commit -m "feat: crossrung-reply stand-in writer — validate, append, stable-serialize (F5 SLICE-4, #14)"
```
### Task 11: Host E2E — round-trip (PUC-2) + newer-major fail-safe (PUC-4)
**Files:**
- Create: `test/e2e/fixtures/workspace/docs/crossrung.md`
- Create: `test/e2e/fixtures/workspace/docs/v2.md`
- Create: `test/e2e/suite/crossrung.test.ts`
- [ ] **Step 1: Add fixtures.**
`test/e2e/fixtures/workspace/docs/crossrung.md`:
```markdown
# Crossrung doc
The portable record travels with ordinary git.
Nothing else here.
```
`test/e2e/fixtures/workspace/docs/v2.md`:
```markdown
# Future doc
Anchored text from the future.
```
- [ ] **Step 2: Write the E2E suite**`test/e2e/suite/crossrung.test.ts` (follows `threads.test.ts` idioms; the stand-in runs via `process.execPath` + `ELECTRON_RUN_AS_NODE`):
```ts
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;
}
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 (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: v1 sections show
const rendered = api.threadController.getRendered(DOC);
assert.strictEqual(rendered.length, 1, "renders what it understands");
// write gestures are refused + warned
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
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)");
});
});
```
- [ ] **Step 3: Run the full E2E suite**
```bash
npm run pretest:e2e && npm run test:e2e
```
Expected: all suites pass (existing threads/attribution/proposals + new crossrung). Note the F2 suites mutate `docs/sample.md` only; the new tests use their own fixture docs, so ordering can't cross-contaminate.
- [ ] **Step 4: Commit**
```bash
git add test/e2e/fixtures/workspace/docs/crossrung.md test/e2e/fixtures/workspace/docs/v2.md test/e2e/suite/crossrung.test.ts
git commit -m "test: host E2E — crossrung round-trip + newer-major fail-safe (F5 SLICE-4, PUC-2/PUC-4, #14)"
```
### Task 12: Docs + full gate (SLICE-5)
**Files:**
- Modify: `README.md` (new cross-rung section)
- [ ] **Step 1: README.** Add a `## Cross-rung format (F5)` section: the sidecar is the published cross-rung contract; normative doc lives at `vscode-cowriting-plugin-content/specs/coauthoring-sidecar-contract.md` (INV-14); machine-checkable schema at `schemas/coauthoring-sidecar.schema.json`; validate any sidecar with `node scripts/validate-sidecar.mjs <file>`; foreign-writer example `scripts/crossrung-reply.mjs`; merge semantics `src/mergeArtifacts.ts` (INV-17); newer-major sidecars are read-only (INV-16); writers preserve unknown fields (INV-15).
- [ ] **Step 2: Full gate**
```bash
npm run typecheck && npm run test && npm run build && npm run pretest:e2e && npm run test:e2e
```
Expected: all green (unit + host E2E; round-trip, merge, fail-safe, unknown-field preservation all exercised — issue #14's acceptance).
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs: cross-rung contract pointers in README (F5 SLICE-5, #14)"
```
### Task 13: PR + merge
- [ ] **Step 1: Push the branch + open the PR** (SSH remote; Gitea — use the established `tea`/API flow from prior sessions or `git push -u origin feature/14-cross-rung-format` + Gitea API):
PR title: `F5: cross-rung format contract + round-trip (#14)`. Body cites the spec, lists the five slices, notes INV-14..17, and `Closes #14`.
- [ ] **Step 2: Merge** (autonomous posture; merge after CI/local gate green), then:
```bash
git checkout main && git pull
```
---
## Self-review notes
- **Spec coverage:** SLICE-1 → Tasks 23; SLICE-2 → Tasks 48; SLICE-3 → Task 9; SLICE-4 → Tasks 1011; SLICE-5 → Task 12. Traceability §8 rows all land: contract+rules (T3), identity (T4/T7), round-trip/no-rehoming (T5/T10/T11), concurrent-writer (T9), validation-outside-extension (T2), unit+E2E (T2T11).
- **Deliberate scope cuts (per spec §1.7):** no `.gitattributes` merge driver, no Gitea API, no UI for merge.
- **Known judgment calls (log to Deferred decisions):** thread-shell lexicographic rule for status divergence (deterministic, documented in contract); preservation implemented at serializer level only (controller rebuild paths own their records — spec §6.2 wording); stand-in re-implements serialization deliberately (byte-fidelity test is the tripwire).