// CoachLedger — private per-party running observations, visible only to the subject (or god view) // The key agent mechanism: longitudinal pattern awareness without weaponization. // Inject pulse keyframe once (function () { if (document.getElementById('ledger-styles')) return; const s = document.createElement('style'); s.id = 'ledger-styles'; s.textContent = ` @keyframes ledger-ring { 0% { box-shadow: 0 0 0 0 var(--pulse-color, oklch(60% 0.2 30 / 0.7)); } 70% { box-shadow: 0 0 0 8px var(--pulse-color, oklch(60% 0.2 30 / 0)); } 100% { box-shadow: 0 0 0 0 var(--pulse-color, oklch(60% 0.2 30 / 0)); } } .ledger-pulse { animation: ledger-ring 1.4s ease-out infinite; } @keyframes ledger-slide-in { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } .ledger-slide-in { animation: ledger-slide-in 0.22s cubic-bezier(0.25,0.46,0.45,0.94) both; } @keyframes ledger-toast { 0% { opacity: 0; transform: translateY(8px) scale(0.95); } 15% { opacity: 1; transform: translateY(0) scale(1); } 75% { opacity: 1; } 100% { opacity: 0; transform: translateY(-4px); } } .ledger-toast { animation: ledger-toast 3.5s ease-out both; } `; document.head.appendChild(s); })(); // Normalise a raw CG.ledger entry (which uses `text`) into the display shape function normEntry(e) { const text = e.text || ''; // Only show a separate note when the agent explicitly wrote one distinct from the main text. // When both come from the same `text` field, showing both is redundant. const distinctNote = (e.note && e.note !== text) ? e.note : ''; return { ...e, title: e.title || text || '(observation)', note: distinctNote, // CG.ledger refs are thread timestamps, not human quotes — skip evidence display evidence: e.evidence || [], severity: e.severity === 'med' ? 'medium' : (e.severity || 'low'), acknowledged: e.acknowledged || false, suggested: e.suggested || null, }; } // Detect live mode (same logic as phases-early.jsx) function isLiveMode() { return (CG.hasKey && CG.hasKey() && CG.getMode && CG.getMode() === 'fresh') || !!(CG.session && CG.session.isGuest && CG.session.isGuest()); } // Hook: load live ledger entries for one seat, re-render on change function useLiveLedger(seat) { const [entries, setEntries] = React.useState([]); React.useEffect(() => { if (!isLiveMode() || !seat || seat === 'god') return; let active = true; const load = async () => { const raw = await CG.ledger.read(seat); if (active) setEntries(raw.map(normEntry)); }; load(); const unsub = CG.ledger.subscribe(() => { if (active) load(); }); return () => { active = false; unsub(); }; }, [seat]); return entries; } function CoachLedger({ seat, variant = "full", currentPhase, entriesOverride, hideHeader = false }) { const S = window.SCENARIO; const [openEntry, setOpenEntry] = React.useState(null); const [dismissed, setDismissed] = React.useState({}); const [acked, setAcked] = React.useState({}); const showA = seat === 'A' || seat === 'god'; const showB = seat === 'B' || seat === 'god'; // Prefer real session names over hardcoded scenario names const sessionNames = (CG.session && CG.session.getNames && CG.session.getNames()) || {}; const resolvedName = (partyKey) => sessionNames[partyKey] || S.parties[partyKey].name; const getEntries = (partyKey) => { if (entriesOverride) return entriesOverride; if (isLiveMode()) return []; // live drawer feeds entries via entriesOverride return (partyKey === 'A' ? S.ledgerA : S.ledgerB) || []; }; const renderLedger = (party, entries, partyKey) => { entries = entries || []; const name = resolvedName(partyKey); const phaseOrder = ['topic', 'intake', 'opening', 'steelman', 'room', 'callout', 'apology', 'artifact']; const grouped = phaseOrder.map(p => ({ phase: p, items: entries.filter(e => e.phase === p && !dismissed[e.id]), })).filter(g => g.items.length > 0); return (
{!hideHeader && (
coach's ledger · {name.toLowerCase()}
{entries.filter(e => !dismissed[e.id]).length} observations
)} {!hideHeader && (
private · only {name.toLowerCase()} sees this · coach adds entries as they notice things. dismiss anything you disagree with; the coach will push back once, then drop it.
)} {grouped.map(g => (
— {g.phase} — {g.phase === currentPhase && current}
{g.items.map(e => ( setOpenEntry(openEntry === e.id ? null : e.id)} onDismiss={() => setDismissed({ ...dismissed, [e.id]: true })} onAck={() => setAcked({ ...acked, [e.id]: true })} /> ))}
))} {grouped.length === 0 && (
nothing in the ledger yet — or you've dismissed everything.
)}
); }; if (variant === "drawer") { return ; } return (
{showA && renderLedger(S.parties.A, getEntries('A'), 'A')} {showB && renderLedger(S.parties.B, getEntries('B'), 'B')}
); } function LedgerEntry({ entry, accent, expanded, onToggle, onDismiss, onAck }) { const sevColor = { high: 'var(--red)', medium: 'oklch(60% 0.15 85)', low: 'var(--ink-3)', '—': 'var(--green)', }[entry.severity]; const kindGlyph = { pattern: '◎', strength: '✦', opening: '↗', }[entry.kind] || '·'; return (
{kindGlyph}
{entry.title}
{entry.kind === 'strength' ? 'strength' : entry.severity === '—' ? '' : entry.severity} {entry.acknowledged && ✓ ack'd}
{entry.note && (
{entry.note}
)} {expanded && (
{entry.evidence && entry.evidence.length > 0 && ( <>
evidence · cited across the conversation
    {entry.evidence.map((ev, i) => (
  • {ev}
  • ))}
)} {entry.suggested && (
suggestion · {entry.suggested}
)}
)}
); } function CoachLedgerDrawer({ seat }) { const [open, setOpen] = React.useState(false); const [seenCount, setSeenCount] = React.useState(0); const [toasts, setToasts] = React.useState([]); // [{ id, text }] const S = window.SCENARIO; const live = isLiveMode(); // Live entries from CG.ledger const liveEntries = useLiveLedger(live ? seat : null); // Canned fallback const cannedEntries = live ? [] : ((seat === 'A' ? S.ledgerA : S.ledgerB) || []); const entries = live ? liveEntries : cannedEntries; const newCount = Math.max(0, entries.length - seenCount); // Toast on new entry while drawer is closed const prevLenRef = React.useRef(entries.length); React.useEffect(() => { const prev = prevLenRef.current; prevLenRef.current = entries.length; if (entries.length > prev && !open) { const newest = entries[entries.length - 1]; const text = newest ? `coach noticed: ${newest.title}` : 'coach added an observation'; const id = Date.now(); setToasts(t => [...t, { id, text }]); setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3600); } }, [entries.length, open]); // Mark all seen when drawer opens const handleOpen = () => { setOpen(true); setSeenCount(entries.length); }; if (seat === 'god') return null; const party = seat === 'A' ? S.parties.A : S.parties.B; const sessionNames = (CG.session && CG.session.getNames && CG.session.getNames()) || {}; const myName = sessionNames[seat] || party.name; const pending = entries.filter(e => !e.acknowledged); const hasNew = newCount > 0; return ( <> {/* Toast notifications — bottom-left to avoid clashing with button */}
{toasts.map(t => (
{t.text}
))}
{/* Drawer toggle button */} {open && (
setOpen(false)} >
e.stopPropagation()} style={{ width: 480, maxWidth: '90vw', height: '100%', background: 'var(--paper)', padding: '24px 22px', overflowY: 'auto', boxShadow: '-4px 0 20px rgba(0,0,0,0.15)', borderLeft: `3px solid ${party.accent}`, }} >
coach's ledger · private · only you see this
what i've noticed, {myName.toLowerCase()}
)} ); } Object.assign(window, { CoachLedger, CoachLedgerDrawer, LedgerEntry });