// Phase 05 · LIVE the room. // // Three panes: // LEFT · private — your coach pre-send pass, your private callouts from the pattern watcher, // your draft composer. // CENTER· public — the posted openings (from phase 03), the accepted steel-mans (phase 04), // and the live message thread. // RIGHT · public — established truths. Either party can propose; BOTH must confirm. // // State (live mode): // shared.posted = [{ id, from:'A'|'B'|'mediator'|'system', kind:'opening'|'steelman'|'msg'|'truth'|'sys', text, ts, ref? }] // shared.truths = [{ id, text, proposedBy:'A'|'B', confirmedA:bool, confirmedB:bool, ts }] // // Pre-existing shared.posted used a simpler shape. We only append; never rewrite. // Messages: after the room-coach returns, user clicks send-original OR send-gentler. // Post-send: pattern watcher runs in the background and may produce a private callout. (function () { const { useState, useEffect, useRef } = React; window.LiveRoomPhase = function LiveRoomPhase({ 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'); // Prerequisite guard const myPosted = shared.openings?.[seat]?.step === 'posted'; const theirPosted = shared.openings?.[otherSeat]?.step === 'posted'; const steel = shared.steelman || {}; const myDone = (steel[`${seat}_attempts`] || []).some(a => a.status === 'accepted'); const theirDone = (steel[`${otherSeat}_attempts`] || []).some(a => a.status === 'accepted'); if (!myPosted || !theirPosted || !myDone || !theirDone) { return (
phase 05 · the room

not ready.

the steel-man gate must be cleared in both directions first.{' '} onPhase && onPhase('steelman')} style={{ color: 'var(--blue)', cursor: 'pointer' }}>← back

); } // Seed the room thread once: openings + accepted steelmans (as "you said" cards). const posted = shared.posted || []; const hasSeed = posted.some(p => p.kind === 'opening'); useEffect(() => { if (hasSeed) return; if (!isHost) return; // host seeds deterministically const out = []; const openA = shared.openings?.A; const openB = shared.openings?.B; if (openA?.synthesis) { out.push({ id: 'op-a', from: 'mediator', kind: 'opening', text: openA.synthesis, attribution: 'A', ts: Date.now() }); } if (openB?.synthesis) { out.push({ id: 'op-b', from: 'mediator', kind: 'opening', text: openB.synthesis, attribution: 'B', ts: Date.now() + 1 }); } const accA = (steel.A_attempts || []).find(a => a.status === 'accepted'); const accB = (steel.B_attempts || []).find(a => a.status === 'accepted'); if (accA) out.push({ id: 'st-a', from: 'A', kind: 'steelman', text: accA.text, attribution: 'B', ts: Date.now() + 2 }); if (accB) out.push({ id: 'st-b', from: 'B', kind: 'steelman', text: accB.text, attribution: 'A', ts: Date.now() + 3 }); if (out.length) { CG.session.setShared({ posted: [...posted, ...out] }); } }, [hasSeed, isHost]); // ----- phase 06 · callout state ----- const callout = shared.callout || null; const [calloutError, setCalloutError] = useState(''); const [calloutLoading, setCalloutLoading] = useState(false); const [calloutRefusal, setCalloutRefusal] = useState(null); // { reason } — shown to requester only const requestCallout = async () => { if (calloutLoading) return; if (callout && callout.status !== 'committed' && callout.status !== 'rejected') return; setCalloutError(''); setCalloutRefusal(null); const id = `co-${Date.now()}-${Math.random().toString(36).slice(2,6)}`; const requesting = { id, requestedBy: seat, status: 'requesting', ackA: false, ackB: false, ts: Date.now() }; if (isHost) CG.session.setShared({ callout: requesting }); else CG.peer.send('state:propose', { callout: requesting }); // Host runs the mediator call; if guest requested, we still want the host to execute, // but we can let the requester run it too (routed via session proxy anyway). setCalloutLoading(true); try { const reading = await CG.agents.patternCaller.call({ requestedBy: seat }); if (!reading.found) { // do NOT post. clear the callout; show a private refusal note to the requester only. setCalloutRefusal({ reason: reading.not_yet_reason || 'not enough to name yet.' }); if (isHost) CG.session.setShared({ callout: null }); else CG.peer.send('state:propose', { callout: { id, status: 'rejected' } }); return; } const proposed = { ...requesting, status: 'proposed', reading }; if (isHost) CG.session.setShared({ callout: proposed }); else { // Guest-initiated: guest runs the call, but only host's setShared is authoritative for // writing the full reading. Route a write through a dedicated path. CG.peer.send('state:propose', { __callout_propose: proposed }); } } catch (e) { setCalloutError(String(e.message || e)); if (isHost) CG.session.setShared({ callout: null }); else CG.peer.send('state:propose', { callout: { id, status: 'rejected' } }); } finally { setCalloutLoading(false); } }; const ackCallout = () => { if (!callout || callout.status !== 'proposed') return; const field = seat === 'A' ? 'ackA' : 'ackB'; const next = { ...callout, [field]: true }; // If both acked, commit: append to posted and clear callout. const bothAcked = (field === 'ackA' ? true : callout.ackA) && (field === 'ackB' ? true : callout.ackB); if (bothAcked) { const entry = { id: `pat-${callout.id}`, from: 'mediator', kind: 'pattern', title: callout.reading.title, text: callout.reading.reading, anchor: callout.reading.anchor, requestedBy: callout.requestedBy, ts: Date.now(), }; if (isHost) { CG.session.setShared({ callout: null, posted: [...(shared.posted || []), entry] }); } else { CG.peer.send('state:propose', { __append_posted: entry, callout: null }); } } else { if (isHost) CG.session.setShared({ callout: next }); else CG.peer.send('state:propose', { callout: next }); } }; const rejectCallout = () => { if (!callout) return; const rejected = { ...callout, status: 'rejected' }; if (isHost) CG.session.setShared({ callout: null }); else CG.peer.send('state:propose', { callout: rejected }); }; // ----- left rail state ----- const [draft, setDraft] = useState(''); const [review, setReview] = useState(null); // { note, gentler_version, issues, ok_to_send } const [reviewing, setReviewing] = useState(false); const [reviewError, setReviewError] = useState(''); const [sending, setSending] = useState(false); // private callouts, keyed per seat — stored in component state only (not shared) const [callouts, setCallouts] = useState([]); // persist draft locally useEffect(() => { (async () => { const s = await CG.store.get(`room_draft_${seat}`); if (s && typeof s === 'string') setDraft(s); })(); }, [seat]); useEffect(() => { CG.store.set(`room_draft_${seat}`, draft); }, [draft, seat]); const publicThread = posted.filter(p => p.kind === 'msg' || p.kind === 'opening' || p.kind === 'steelman'); // Presence useEffect(() => { let activity = 'in the room'; if (sending) activity = 'posting'; else if (reviewing) activity = 'reviewing with coach'; else if ((draft || '').trim().length > 0) activity = 'drafting'; try { CG.session.setCursor && CG.session.setCursor({ activity }); } catch (e) {} }, [sending, reviewing, draft]); const runReview = async () => { const t = draft.trim(); if (!t || reviewing) return; setReviewing(true); setReviewError(''); setReview(null); try { const r = await CG.agents.roomCoach.review(seat, t, publicThread); setReview(r); } catch (e) { setReviewError(String(e.message || e)); } finally { setReviewing(false); } }; const sendPost = (text) => { if (!text || sending) return; setSending(true); const entry = { id: `m-${Date.now()}-${Math.random().toString(36).slice(2,6)}`, from: seat, kind: 'msg', text, ts: Date.now() }; // posted is append-only; use the __append_posted mechanism on cg-session for guest if (isHost) { CG.session.setShared({ posted: [...(shared.posted || []), entry] }); } else { // guest appends via dedicated protocol (supported by _applyGuestProposal) CG.peer.send('state:propose', { __append_posted: entry }); } setDraft(''); setReview(null); setSending(false); CG.store.set(`room_draft_${seat}`, ''); // Run the pattern watcher in the background (private) (async () => { try { const c = await CG.agents.patternWatcher.watch(seat, [...publicThread, entry]); if (c) setCallouts(cs => [...cs, c]); } catch {} })(); }; // ----- establish-truth flow (right rail) ----- const [truthDraft, setTruthDraft] = useState(''); const truths = shared.truths || []; const proposeTruth = () => { const t = truthDraft.trim(); if (!t) return; const entry = { id: `t-${Date.now()}-${Math.random().toString(36).slice(2,6)}`, text: t, proposedBy: seat, confirmedA: seat === 'A', confirmedB: seat === 'B', ts: Date.now() }; const nextTruths = [...truths, entry]; if (isHost) CG.session.setShared({ truths: nextTruths }); else CG.peer.send('state:propose', { truths: nextTruths, __truth_append: entry }); setTruthDraft(''); }; const confirmTruth = (id) => { const nextTruths = truths.map(t => t.id === id ? { ...t, [seat === 'A' ? 'confirmedA' : 'confirmedB']: true } : t); if (isHost) CG.session.setShared({ truths: nextTruths }); else CG.peer.send('state:propose', { truths: nextTruths, __truth_confirm: { id, seat } }); }; return (
{/* Header */}
phase 05 · the room · live

public record. your coach runs every message before it posts.

{/* Three panes */}
{/* LEFT */}
sendPost(draft.trim())} onSendGentler={() => sendPost((review?.gentler_version || draft).trim())} callouts={callouts} dismissCallout={(i) => setCallouts(cs => cs.filter((_, k) => k !== i))} otherName={otherName} callout={callout} calloutLoading={calloutLoading} calloutError={calloutError} calloutRefusal={calloutRefusal} dismissRefusal={() => setCalloutRefusal(null)} onRequestCallout={requestCallout} seatSelf={seat} />
{/* CENTER */}
{callout && callout.status === 'proposed' && callout.reading && ( )} {callout && callout.status === 'requesting' && (
mediator · reading the room…
{callout.requestedBy === seat ? 'you asked for a pattern reading.' : `${callout.requestedBy === 'A' ? (names.A || 'host') : (names.B || 'guest')} asked for a pattern reading.`}
)}
{/* RIGHT */}
onPhase && onPhase('apology')} />
); }; // -------- LEFT · private pane -------- function LeftPrivatePane({ seat, draft, setDraft, review, setReview, reviewing, reviewError, onReview, onSendOriginal, onSendGentler, callouts, dismissCallout, otherName, callout, calloutLoading, calloutError, calloutRefusal, dismissRefusal, onRequestCallout, seatSelf }) { const canRequest = !calloutLoading && (!callout || callout.status === 'committed' || callout.status === 'rejected'); const calloutBusy = callout && (callout.status === 'requesting' || callout.status === 'proposed'); return (
you & your coach
{/* phase 06 · pattern callout trigger */}
pattern callout · mediator
stuck in a loop? ask the mediator to name the pattern. both of you decide whether to accept it.
{calloutError && (
{calloutError}
)} {calloutRefusal && (
mediator declined · private to you
{calloutRefusal.reason}
)}
{/* pattern-watcher callouts */} {callouts.length > 0 && (
{callouts.slice().reverse().map((c, i) => (
pattern · {c.severity}
{c.text}
references: "{c.ledger_ref}"
))}
)}
draft · nothing's posted until you choose to send