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