#!/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}`);