// Phase 08 · LIVE artifact. // // The final co-authored record. Mediator drafts from the public record. Both parties edit // (with co-sign on each edit). Both sign to freeze. Export as markdown. // // UI — single column, five sections. Each bullet has a "·" menu: edit, remove. // Edits go into shared.artifact.edits and require approval from the OTHER party to apply. // Each seat can also propose a new bullet ("+ add") which also requires co-sign. // // When shared.artifact.status === 'signed', the artifact is frozen. Download button appears. (function () { const { useState, useEffect } = React; window.LiveArtifactPhase = function LiveArtifactPhase({ seat, onPhase }) { const [, tick] = useState(0); useEffect(() => CG.session.onState(() => tick(t => t + 1)), []); const shared = CG.session.shared || {}; const names = (CG.session.getNames && CG.session.getNames()) || {}; const mp = CG.session.isMultiplayer && CG.session.isMultiplayer(); const isHost = !mp || seat === 'A'; const otherSeat = seat === 'A' ? 'B' : 'A'; const myName = names[seat] || (seat === 'A' ? 'host' : 'guest'); const otherName = names[otherSeat] || (otherSeat === 'A' ? 'host' : 'guest'); const art = shared.artifact || null; const [drafting, setDrafting] = useState(false); const [draftError, setDraftError] = useState(''); // Derive signing state defensively — needed by the presence effect below (must stay above early returns) const signed = art?.status === 'signed'; const mySigned = art ? (seat === 'A' ? !!art.signedA : !!art.signedB) : false; const theirSigned = art ? (seat === 'A' ? !!art.signedB : !!art.signedA) : false; // Presence — always called, no-ops until the artifact is ready useEffect(() => { if (!art?.draft) return; let activity = 'reviewing artifact'; if (signed) activity = 'signed · complete'; else if (mySigned && !theirSigned) activity = `waiting on ${otherName}`; else if (!mySigned && theirSigned) activity = 'final review'; else if (art.edits && art.edits.some(e => e.proposedBy === seat && !e.applied)) activity = 'proposing edits'; try { CG.session.setCursor && CG.session.setCursor({ activity }); } catch (e) {} }, [signed, mySigned, theirSigned]); // Guard: need phase 07 to be complete const ap = shared.apology || {}; const apDone = (ap.continueA && ap.continueB) || // tolerate older sessions: also accept if both parties skipped or responded ((ap.A?.status === 'skipped' || (ap.A?.status === 'offered' && ap.B?.response)) && (ap.B?.status === 'skipped' || (ap.B?.status === 'offered' && ap.A?.response))); if (!apDone && !art) { return (
phase 08 · artifact

not ready.

finish the apology on-ramp first.{' '} onPhase && onPhase('apology')} style={{ color: 'var(--blue)', cursor: 'pointer' }}>← back

); } const runDraft = async () => { if (drafting) return; setDrafting(true); setDraftError(''); try { const draft = await CG.agents.artifactMediator.draft(); // only host writes the authoritative draft if (isHost) { CG.session.setShared({ artifact: { status: 'reviewing', draft, edits: [], signedA: false, signedB: false } }); } else { // guest doesn't normally run this; but if they do somehow, they can't write the draft. // Keep it in local state as a preview; but guest should really wait for host. We show an error. setDraftError('only the host can commit the initial draft. ask them to click "ask the mediator to draft".'); } } catch (e) { setDraftError(String(e.message || e)); } finally { setDrafting(false); } }; // no artifact yet — show the drafting invitation if (!art || !art.draft) { return (
phase 08 · artifact

a record you both keep.

the mediator will draft the shortest true thing from the public record: what you disagreed about, what you came to agree on, what each of you offered, and what remains open. you'll both edit it together. you both have to sign.

{!isHost && (
waiting for {otherName} to ask the mediator for a draft.
)} {isHost && ( )} {draftError && (
error
{draftError}
)}
); } // artifact present — show the document const proposeEdit = (edit) => { const curEdits = (art.edits || []); const newEdits = [...curEdits, { ...edit, id: `e-${Date.now()}-${Math.random().toString(36).slice(2,6)}`, seat, approvedA: seat === 'A', approvedB: seat === 'B', ts: Date.now() }]; if (isHost) CG.session.setShared({ artifact: { ...art, edits: newEdits } }); else CG.peer.send('state:propose', { artifact: { edits: newEdits } }); }; const approveEdit = (id) => { // guest approves via the merge-on-append contract; host materializes applied edits into the draft. if (isHost) { const e = (art.edits || []).find(x => x.id === id); if (!e) return; // flip host's side const next = { ...art, edits: art.edits.map(x => x.id === id ? { ...x, approvedA: true } : x) }; const applied = applyIfBothApproved(next, id); CG.session.setShared({ artifact: applied }); } else { CG.peer.send('state:propose', { artifact: { edits: [{ id, approvedB: true }] } }); } }; const rejectEdit = (id) => { // remove the edit — allowed on either side as a local decision; host is authoritative. if (isHost) { CG.session.setShared({ artifact: { ...art, edits: art.edits.filter(x => x.id !== id) } }); } else { // guest can't force removal; they can only decline to approve. But we can propose a rejection // by sending the edit back with approvedB=false (no-op under current guard). For v1: guest has // to ask the host to reject. UX fallback: disable the reject button for guests. } }; const sign = () => { if (seat === 'A') { if (isHost) { const next = { ...art, signedA: true }; if (next.signedB) { next.status = 'signed'; next.signedAt = Date.now(); } CG.session.setShared({ artifact: next }); } } else { if (isHost) { const next = { ...art, signedB: true }; if (next.signedA) { next.status = 'signed'; next.signedAt = Date.now(); } CG.session.setShared({ artifact: next }); } else { CG.peer.send('state:propose', { artifact: { signedB: true } }); } } }; const draft = art.draft; const pendingEdits = (art.edits || []).filter(e => !(e.approvedA && e.approvedB)); return (
phase 08 · artifact

the record.

{signed && ( )}
{signed && (
signed by both parties on {new Date(art.signedAt || Date.now()).toLocaleString()}. this is frozen — a historical record.
)} {pendingEdits.length > 0 && !signed && (
pending edits · {pendingEdits.length}
{pendingEdits.map(e => ( approveEdit(e.id)} onReject={() => rejectEdit(e.id)} /> ))}
)} {!signed && (
when this is the truest short thing you can both sign
{mySigned ? 'you ✓' : 'you ·'} {theirSigned ? `· ${otherName} ✓` : `· ${otherName} pending`}
{pendingEdits.length > 0 && (
there are {pendingEdits.length} pending edit{pendingEdits.length === 1 ? '' : 's'}. resolve them before signing.
)}
)}
); }; // -------- helpers -------- function applyIfBothApproved(art, editId) { const edit = (art.edits || []).find(e => e.id === editId); if (!edit || !(edit.approvedA && edit.approvedB)) return art; const draft = { ...art.draft }; const list = [...(draft[edit.section] || [])]; if (edit.op === 'replace' && typeof edit.idx === 'number') { if (list[edit.idx]) list[edit.idx] = { ...list[edit.idx], text: edit.text, ref: edit.ref || list[edit.idx].ref }; } else if (edit.op === 'remove' && typeof edit.idx === 'number') { list.splice(edit.idx, 1); } else if (edit.op === 'add') { list.push({ text: edit.text, ref: edit.ref || 'user-added' }); } draft[edit.section] = list; return { ...art, draft, edits: art.edits.filter(e => e.id !== editId) }; } function PendingEditRow({ edit, seat, names, isHost, onApprove, onReject }) { const byName = edit.seat === 'A' ? (names.A || 'host') : (names.B || 'guest'); const myApproved = seat === 'A' ? edit.approvedA : edit.approvedB; const opLabel = edit.op === 'replace' ? 'edit' : edit.op === 'remove' ? 'remove' : 'add'; return (
{byName} · {opLabel} · {edit.section.replace(/_/g, ' ')}{typeof edit.idx === 'number' ? ` #${edit.idx + 1}` : ''}
{edit.op !== 'remove' && (
{edit.text}
)} {myApproved ? (
you approved · waiting for the other
) : (
{isHost && }
)}
); } function ArtifactDoc({ draft, names, seat, signed, onPropose }) { return (
{draft.topic}
{draft.acknowledgments && draft.acknowledgments.length > 0 && ( )} {draft.action_items && draft.action_items.length > 0 && ( )} {draft.parking_lot && draft.parking_lot.length > 0 && ( )}
); } function Section({ label, children }) { return (
{label}
{children}
); } function BulletSection({ label, section, items, signed, onPropose }) { const [addMode, setAddMode] = useState(false); const [addText, setAddText] = useState(''); return (
{label}
{!signed && !addMode && ( )}
{(!items || items.length === 0) && !addMode && (
(none)
)} {addMode && (