# 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 // 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 "); 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/.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 F2–F4 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 { const known: Record = { 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, src: unknown): Record { const rec = src as Record; for (const k of Object.keys(rec).filter((k) => !(k in known)).sort()) { known[k] = rec[k]; } return known; } ``` 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): 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(); 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("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(); 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; ours.futureSection = [1]; const theirs = base() as Artifact & Record; theirs.otherFuture = "x"; const { merged, conflicts } = mergeArtifacts(ours, theirs); expect((merged as unknown as Record).futureSection).toEqual([1]); expect((merged as unknown as Record).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; /** 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(a: T, b: T): T { return stableStringify(a) >= stableStringify(b) ? a : b; } function unionById( 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).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).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 \ // [--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 [--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 { 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 { 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 }>; }).docs; const vsThread = docs.get(docRel)?.vsThreads.get(threadId); return vsThread ? vsThread.comments.length : -1; } async function until(cond: () => boolean, ms = 5000): Promise { 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 `; 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 2–3; SLICE-2 → Tasks 4–8; SLICE-3 → Task 9; SLICE-4 → Tasks 10–11; 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 (T2–T11). - **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).