diff --git a/src/model.ts b/src/model.ts index 968578b..4ac79f1 100644 --- a/src/model.ts +++ b/src/model.ts @@ -61,6 +61,22 @@ export interface AttributionRecord { turnId?: string; } +/** F4 (spec §6.3, INV-13): a PENDING machine edit — state, not history. */ +export interface Proposal { + id: string; + /** shared anchors map; fingerprint.text IS the exact target text (INV-11). */ + anchorId: string; + /** the full proposed text for the anchored range. */ + replacement: string; + author: Provenance; + /** ISO-8601. */ + createdAt: string; + /** groups N proposals born of one live turn. */ + turnId?: string; + /** what the human asked for (review context). */ + instruction?: string; +} + export interface Artifact { schemaVersion: number; /** repo-relative path; the sidecar key. */ @@ -70,8 +86,8 @@ export interface Artifact { threads: Thread[]; /** F3 fills this (spec §6.3, INV-4): typed attribution records anchored via anchors[]. */ attributions: AttributionRecord[]; - /** F4 extension point — reuses anchors[] + provenance; not in F2. */ - proposals: unknown[]; + /** F4 (spec §6.3, INV-13): pending proposals on shared anchors[] + Provenance. */ + proposals: Proposal[]; } export function emptyArtifact(docPath: string): Artifact { @@ -139,7 +155,15 @@ export function serializeArtifact(a: Artifact): string { updatedAt: at.updatedAt, ...(at.turnId !== undefined ? { turnId: at.turnId } : {}), })), - proposals: a.proposals, + 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 } : {}), + })), }; return JSON.stringify(canonical, null, 2) + "\n"; } diff --git a/test/proposalModel.test.ts b/test/proposalModel.test.ts new file mode 100644 index 0000000..ad8d3a5 --- /dev/null +++ b/test/proposalModel.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + emptyArtifact, + serializeArtifact, + type Artifact, + type Proposal, +} from "../src/model"; + +const agent = { + kind: "agent" as const, + id: "claude", + agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "s1" }, +}; + +describe("Proposal model (spec §6.3, INV-13)", () => { + it("serializes proposals with stable field order and survives a round-trip", () => { + const a = emptyArtifact("docs/x.md"); + a.anchors["a_1"] = { + fingerprint: { text: "old text", before: "", after: "", lineHint: 0 }, + }; + const p: Proposal = { + id: "pr_1", + anchorId: "a_1", + replacement: "new text", + author: agent, + createdAt: "2026-06-10T00:00:00.000Z", + turnId: "turn-1", + instruction: "tighten", + }; + a.proposals.push(p); + const reloaded = JSON.parse(serializeArtifact(a)) as Artifact; + expect(reloaded.proposals).toHaveLength(1); + expect(reloaded.proposals[0]).toEqual(p); + // optional fields are OMITTED (not null) when absent + const bare: Proposal = { + id: "pr_2", anchorId: "a_1", replacement: "r", author: agent, + createdAt: "2026-06-10T00:00:00.000Z", + }; + a.proposals.push(bare); + const again = JSON.parse(serializeArtifact(a)) as Artifact; + expect("turnId" in again.proposals[1]).toBe(false); + expect("instruction" in again.proposals[1]).toBe(false); + // serialization is byte-stable (INV-2) + expect(serializeArtifact(again)).toBe(serializeArtifact(a)); + }); +});