// 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}
)}
{ e.stopPropagation(); if (onAck) onAck(); }}>
{entry.acknowledged ? "acknowledged" : "acknowledge"}
{ e.stopPropagation(); onDismiss(); }}>
dismiss · i disagree
)}
);
}
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 */}
coach's ledger · private
{entries.length} observations · {pending.length} pending
{hasNew && (
{newCount} new
)}
{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()}
setOpen(false)}>close
)}
>
);
}
Object.assign(window, { CoachLedger, CoachLedgerDrawer, LedgerEntry });