diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 2749b46e..d1a10785 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1024,6 +1024,12 @@ "description": "Security frameworks, accessibility guidelines, performance optimization, and code quality best practices for building secure, maintainable, and high-performance applications.", "version": "1.0.0" }, + { + "name": "signals-dashboard", + "source": "extensions/signals-dashboard", + "description": "Real-time agent coordination dashboard for The Workshop. Shows desk status, signal types (done, checkpoint, blocked, hands-up, partnership), intent text, outcome pairing with honesty gap, token usage, and stash/restore controls.", + "version": "0.1.0" + }, { "name": "site-studio", "source": "extensions/site-studio", diff --git a/extensions/signals-dashboard/.github/plugin/plugin.json b/extensions/signals-dashboard/.github/plugin/plugin.json new file mode 100644 index 00000000..799ed39e --- /dev/null +++ b/extensions/signals-dashboard/.github/plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "signals-dashboard", + "description": "Real-time agent coordination dashboard for The Workshop. Shows desk status, signal types (done, checkpoint, blocked, hands-up, partnership), intent text, outcome pairing with honesty gap, token usage, and stash/restore controls.", + "version": "0.1.0", + "author": { + "name": "jennyf19", + "url": "https://github.com/jennyf19" + }, + "keywords": [ + "agent-signals", + "dashboard", + "multi-agent", + "coordination", + "canvas" + ], + "extensions": "." +} diff --git a/extensions/signals-dashboard/extension.mjs b/extensions/signals-dashboard/extension.mjs new file mode 100644 index 00000000..39a593c0 --- /dev/null +++ b/extensions/signals-dashboard/extension.mjs @@ -0,0 +1,711 @@ +// Extension: signals-dashboard +// Live dashboard showing agent signals from workshop desks. +// Scans desks/*/.signals/ for JSON files, renders the latest signal per desk. +// Supports stashing desks (48hr hold) and restoring them. + +import { createServer } from "node:http"; +import { readdir, readFile, writeFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; + +const servers = new Map(); +const STASH_TTL_MS = 48 * 60 * 60 * 1000; + +// --- Stash management --- + +async function readStash(workshopDir) { + const fp = join(workshopDir, ".desk-stash.json"); + try { + const raw = await readFile(fp, "utf-8"); + const stash = JSON.parse(raw); + const now = Date.now(); + const live = stash.filter(e => (now - new Date(e.stashedAt).getTime()) < STASH_TTL_MS); + if (live.length !== stash.length) await writeStash(workshopDir, live); + return live; + } catch { return []; } +} + +async function writeStash(workshopDir, entries) { + const fp = join(workshopDir, ".desk-stash.json"); + await writeFile(fp, JSON.stringify(entries, null, 2), "utf-8"); +} + +async function stashDesk(workshopDir, deskName) { + const stash = await readStash(workshopDir); + if (stash.some(e => e.name === deskName)) return stash; + stash.push({ name: deskName, stashedAt: new Date().toISOString() }); + await writeStash(workshopDir, stash); + return stash; +} + +async function restoreDesk(workshopDir, deskName) { + let stash = await readStash(workshopDir); + stash = stash.filter(e => e.name !== deskName); + await writeStash(workshopDir, stash); + return stash; +} + +// --- Signal reading --- + +async function scanSignals(workshopDir) { + const results = []; + for (const subdir of ["desks", "classroom"]) { + const parent = join(workshopDir, subdir); + let entries; + try { entries = await readdir(parent, { withFileTypes: true }); } + catch { continue; } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + const sigDir = join(parent, entry.name, ".signals"); + let sigFiles; + try { sigFiles = await readdir(sigDir); } + catch { + results.push({ + deskName: entry.name, signalType: "none", agentName: entry.name, + confidence: 0, accuracy: 0, completeness: 0, intent: 0, + whatWorked: "", whatWasHard: "", skillGap: "", + escalationReason: null, escalationBlocked: null, recommendation: null, + emittedAt: null, signalCount: 0, tokensIn: 0, tokensOut: 0, model: null, + }); + continue; + } + + const jsonFiles = sigFiles.filter(f => f.endsWith(".json")); + if (jsonFiles.length === 0) { + results.push({ + deskName: entry.name, signalType: "none", agentName: entry.name, + confidence: 0, accuracy: 0, completeness: 0, intent: 0, + whatWorked: "", whatWasHard: "", skillGap: "", + escalationReason: null, escalationBlocked: null, recommendation: null, + emittedAt: null, signalCount: 0, tokensIn: 0, tokensOut: 0, model: null, + }); + continue; + } + + // Read all signals, separate by type, find latest execution/partnership + any outcome signals + let latest = null, latestTime = 0; + const allSignals = []; + for (const f of jsonFiles) { + const fp = join(sigDir, f); + try { + const s = await stat(fp); + const raw = await readFile(fp, "utf-8"); + const parsed = JSON.parse(raw); + allSignals.push({ parsed, mtimeMs: s.mtimeMs, path: fp }); + // Latest non-outcome signal (execution, partnership, escalation) + if ((parsed.signal_type || "execution") !== "outcome" && s.mtimeMs > latestTime) { + latestTime = s.mtimeMs; latest = { parsed, mtimeMs: s.mtimeMs }; + } + } catch {} + } + if (!latest) continue; + try { + const sig = latest.parsed; + const intentRaw = sig.intent || sig.self_assessment?.intent || null; + + // Find outcome signal matched by run_id (if any) + let outcome = null; + if (sig.run_id) { + const outcomeSignals = allSignals + .filter(s => s.parsed.signal_type === "outcome" && s.parsed.run_id === sig.run_id); + if (outcomeSignals.length > 0) { + outcome = outcomeSignals.sort((a, b) => b.mtimeMs - a.mtimeMs)[0].parsed; + } + } + // Also check for any recent outcome (within 1hr of latest signal) if no run_id match + if (!outcome) { + const recentOutcomes = allSignals + .filter(s => s.parsed.signal_type === "outcome" && Math.abs(s.mtimeMs - latestTime) < 3600000) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + if (recentOutcomes.length > 0) outcome = recentOutcomes[0].parsed; + } + + // Compute honesty gap if we have both self-assessment and outcome + let honestyGap = null; + if (outcome && sig.self_assessment) { + const selfConf = sig.self_assessment.confidence || 0; + const outcomeRating = outcome.quality_rating || 0; + if (selfConf > 0 && outcomeRating > 0) { + honestyGap = Math.abs(selfConf - outcomeRating); + } + } + + results.push({ + deskName: entry.name, + signalType: sig.signal_type || "execution", + subtype: sig.subtype || sig.signal_type || "execution", + agentName: sig.agent_name || entry.name, + intentText: typeof intentRaw === "string" ? intentRaw : null, + intentScore: typeof intentRaw === "number" ? intentRaw : 0, + confidence: sig.self_assessment?.confidence || 0, + accuracy: sig.self_assessment?.accuracy || 0, + completeness: sig.self_assessment?.completeness || 0, + whatWorked: sig.patterns?.what_worked || "", + whatWasHard: sig.patterns?.what_was_hard || "", + skillGap: sig.patterns?.skill_gap || "", + escalationReason: sig.escalation?.reason || null, + escalationBlocked: sig.escalation?.blocked_on || null, + recommendation: sig.escalation?.recommendation || null, + emittedAt: new Date(latestTime).toISOString(), + signalCount: jsonFiles.length, + tokensIn: sig.usage?.tokens_in || 0, + tokensOut: sig.usage?.tokens_out || 0, + model: sig.usage?.model || null, + // Outcome signal fields + outcomeRating: outcome?.quality_rating || null, + outcomeEffort: outcome?.effort_to_merge || null, + outcomeIssues: outcome?.issues_found || [], + outcomeAgent: outcome?.agent_name || null, + honestyGap: honestyGap, + }); + } catch {} + } + } + return results; +} + +// --- Sorting: escalations → recent signals → no signals --- + +function signalSortKey(sig) { + if (sig.signalType === "escalation") return 0; + if (sig.signalType === "execution") return 1; + if (sig.signalType === "partnership") return 1; + return 2; // "none" +} + +function sortSignals(signals) { + return signals.sort((a, b) => { + const ka = signalSortKey(a), kb = signalSortKey(b); + if (ka !== kb) return ka - kb; + if (a.emittedAt && b.emittedAt) return new Date(b.emittedAt) - new Date(a.emittedAt); + if (a.emittedAt) return -1; + if (b.emittedAt) return 1; + return a.deskName.localeCompare(b.deskName); + }); +} + +// --- HTML rendering --- + +function esc(s) { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} +function truncate(s, len) { + return s.length > len ? s.slice(0, len) + "…" : s; +} +function formatTokens(n) { + if (!n) return null; + if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return `${n}`; +} + +function scoreBar(value, label, max = 5) { + const pct = (value / max) * 100; + const color = value >= 4 ? "#22c55e" : value >= 3 ? "#eab308" : value >= 1 ? "#ef4444" : "#262626"; + return `
+
+ ${label} + ${value}/5 +
+
+
+
+
`; +} + +function timeSince(isoDate) { + if (!isoDate) return "—"; + const diff = Date.now() - new Date(isoDate).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +function timeRemaining(stashedAt) { + const remaining = STASH_TTL_MS - (Date.now() - new Date(stashedAt).getTime()); + if (remaining <= 0) return "expiring"; + const hrs = Math.floor(remaining / 3600000); + return hrs >= 1 ? `${hrs}h left` : `${Math.floor(remaining / 60000)}m left`; +} + +function avgScore(signals) { + const withSignals = signals.filter(s => s.signalType !== "none"); + if (withSignals.length === 0) return null; + const avg = (field) => { + const vals = withSignals.map(s => s[field]).filter(v => v > 0); + return vals.length ? (vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(1) : "—"; + }; + return { confidence: avg("confidence"), accuracy: avg("accuracy"), completeness: avg("completeness"), intent: avg("intentScore") }; +} + +function renderSummaryBar(activeSignals) { + const escalations = activeSignals.filter(s => s.signalType === "escalation").length; + const withSignals = activeSignals.filter(s => s.signalType !== "none").length; + const awaiting = activeSignals.filter(s => s.signalType === "none").length; + const avg = avgScore(activeSignals); + + const totalTokens = activeSignals.reduce((sum, s) => sum + (s.tokensIn || 0) + (s.tokensOut || 0), 0); + const withOutcomes = activeSignals.filter(s => s.outcomeRating !== null).length; + const avgGap = (() => { + const gaps = activeSignals.filter(s => s.honestyGap !== null).map(s => s.honestyGap); + return gaps.length ? (gaps.reduce((a, b) => a + b, 0) / gaps.length).toFixed(1) : null; + })(); + + const escBadge = escalations > 0 + ? `⚠ ${escalations} escalation${escalations > 1 ? "s" : ""}` + : ""; + + const tokenBadge = totalTokens > 0 + ? `🪙 ${formatTokens(totalTokens)}` + : ""; + + const calibrationBadge = withOutcomes > 0 + ? `🔍 gap ${avgGap}` + : ""; + + const avgBlock = avg ? ` +
+ intent ${avg.intent} + conf ${avg.confidence} + acc ${avg.accuracy} + comp ${avg.completeness} +
` : ""; + + return ` +
+
+ ${activeSignals.length} desk${activeSignals.length !== 1 ? "s" : ""} + ${withSignals} reporting · ${awaiting} awaiting + ${tokenBadge} + ${calibrationBadge} + ${escBadge} +
+ ${avgBlock} +
`; +} + +function renderSignalCard(sig) { + const isEscalation = sig.signalType === "escalation"; + const isPartnership = sig.signalType === "partnership"; + const noSignal = sig.signalType === "none"; + const borderColor = isEscalation ? "#dc2626" : noSignal ? "#1e293b" : "#1e3a5f"; + const bgColor = isEscalation ? "#0f0604" : "#0f172a"; + + const typeLabel = isEscalation + ? (sig.subtype === "blocked" + ? `⚠ BLOCKED` + : `⚠ HANDS-UP`) + : noSignal + ? `📡 awaiting` + : isPartnership + ? `🤝 partnership` + : sig.subtype === "done" + ? `✓ done` + : `✓ checkpoint`; + + const stashBtn = ``; + + const openBtnStyle = isEscalation + ? "background:#7f1d1d;border:1px solid #dc2626;color:#fca5a5;padding:2px 10px;border-radius:4px;font-size:11px;cursor:pointer;font-weight:600;transition:all .15s;" + : "background:none;border:1px solid #1e3a5f;color:#7dd3fc;padding:2px 8px;border-radius:4px;font-size:11px;cursor:pointer;transition:all .15s;"; + const openBtn = noSignal ? "" : ``; + + let escalationBlock = ""; + if (isEscalation && sig.escalationReason) { + escalationBlock = ` +
+
Blocked on:
+
${esc(sig.escalationBlocked || sig.escalationReason)}
+ ${sig.recommendation ? `
→ ${esc(sig.recommendation)}
` : ""} +
`; + } + + // --- Intent text (execution signals with text intent) --- + const intentBlock = sig.intentText ? ` +
+ ${esc(sig.intentText)} +
` : ""; + + // --- Scores: shown for partnership signals, or legacy execution signals with numeric scores --- + const hasScores = isPartnership + ? true + : (sig.intentScore > 0 || sig.confidence > 0 || sig.accuracy > 0 || sig.completeness > 0); + + const scoresBlock = noSignal ? ` +
+ No signals yet — this desk is waiting for its first session. +
` : isPartnership ? ` +
+ ${scoreBar(sig.intentScore, "intent")} + ${scoreBar(sig.confidence, "confidence")} + ${scoreBar(sig.accuracy, "accuracy")} + ${scoreBar(sig.completeness, "completeness")} +
` : hasScores ? ` +
+ scores +
+ ${sig.intentScore > 0 ? scoreBar(sig.intentScore, "intent") : ""} + ${sig.confidence > 0 ? scoreBar(sig.confidence, "confidence") : ""} + ${sig.accuracy > 0 ? scoreBar(sig.accuracy, "accuracy") : ""} + ${sig.completeness > 0 ? scoreBar(sig.completeness, "completeness") : ""} +
+
` : ""; + + // --- Patterns: primary for execution, secondary for partnership --- + const patternsBlock = (sig.whatWorked || sig.whatWasHard || sig.skillGap) ? ` +
+ ${sig.whatWorked ? `
${esc(truncate(sig.whatWorked, 160))}
` : ""} + ${sig.whatWasHard ? `
${esc(truncate(sig.whatWasHard, 160))}
` : ""} + ${sig.skillGap ? `
${esc(truncate(sig.skillGap, 160))}
` : ""} +
` : ""; + + // --- Outcome signal / honesty gap --- + let outcomeBlock = ""; + if (sig.outcomeRating !== null && !noSignal) { + const gapColor = sig.honestyGap === null ? "#475569" + : sig.honestyGap <= 1 ? "#22c55e" + : sig.honestyGap === 2 ? "#eab308" + : "#ef4444"; + const gapLabel = sig.honestyGap === null ? "—" + : sig.honestyGap <= 1 ? "well-calibrated" + : sig.honestyGap === 2 ? "moderate gap" + : "significant gap"; + const effortColor = sig.outcomeEffort === "minimal" ? "#22c55e" + : sig.outcomeEffort === "moderate" ? "#eab308" + : sig.outcomeEffort === "significant" ? "#ef4444" : "#475569"; + + outcomeBlock = ` +
+
+ 🔍 outcome${sig.outcomeAgent ? ` · ${esc(sig.outcomeAgent)}` : ""} + ${sig.honestyGap !== null ? `${gapLabel} (gap: ${sig.honestyGap})` : ""} +
+
+
+
+ quality + ${sig.outcomeRating}/5 +
+
+
+
+
+ ${sig.outcomeEffort || "—"} effort +
+ ${sig.outcomeIssues?.length ? `
+ ${sig.outcomeIssues.map(i => `
· ${esc(truncate(i, 120))}
`).join("")} +
` : ""} +
`; + } + + return ` +
+
+
+ ${esc(sig.deskName)} + ${typeLabel} +
+
+ ${(sig.tokensIn || sig.tokensOut) ? `🪙 ${formatTokens(sig.tokensIn + sig.tokensOut)}` : ""} + ${timeSince(sig.emittedAt)}${sig.signalCount ? ` · ${sig.signalCount}` : ""} + ${openBtn} + ${stashBtn} +
+
+ ${isPartnership ? `${scoresBlock}${patternsBlock}` : `${intentBlock}${patternsBlock}${scoresBlock}`} + ${outcomeBlock} + ${escalationBlock} +
`; +} + +function renderStashedCard(entry) { + return ` +
+
+ ${esc(entry.name)} + ${timeRemaining(entry.stashedAt)} +
+ +
`; +} + +function renderDashboard(signals, stashed) { + const activeSignals = sortSignals(signals.filter(s => !stashed.some(e => e.name === s.deskName))); + + const cards = activeSignals.length > 0 + ? activeSignals.map(renderSignalCard).join("") + : `
+
🪨
+
No active desks yet
+
+
Get started
+
+ Ask the Workshop TA in chat: +
+
+ "open a desk called scanning in ~/my-workshop" +
+
+ The TA uses the desk-open skill to create a desk with a journal. Once a desk emits signals, they'll appear here automatically. +
+
+
💡 Quick commands to try:
+
+ • "open a desk for code review"
+ • "what's everyone working on?"
+ • "show me the signals" +
+
+
+
`; + + const summaryBar = activeSignals.length > 0 ? renderSummaryBar(activeSignals) : ""; + + const stashedSection = stashed.length > 0 ? ` +
+
+ Stashed · ${stashed.length} +
+ ${stashed.map(renderStashedCard).join("")} +
` : ""; + + return ` + + + + Cairn · Signals + + + +
+

🪨 Cairn

+ live +
+
+ ${summaryBar} + ${cards} + ${stashedSection} +
+ + + +`; +} + +// --- Server --- + +async function startServer(instanceId, workshopDir) { + const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + if (req.method === "POST" && url.pathname.startsWith("/api/stash/")) { + const deskName = decodeURIComponent(url.pathname.split("/api/stash/")[1]); + await stashDesk(workshopDir, deskName); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + if (req.method === "POST" && url.pathname.startsWith("/api/restore/")) { + const deskName = decodeURIComponent(url.pathname.split("/api/restore/")[1]); + await restoreDesk(workshopDir, deskName); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + if (req.method === "POST" && url.pathname.startsWith("/api/open/")) { + const deskName = decodeURIComponent(url.pathname.split("/api/open/")[1]); + for (const subdir of ["desks", "classroom"]) { + const deskPath = join(workshopDir, subdir, deskName); + try { + const s = await stat(deskPath); + if (s.isDirectory()) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, deskName, deskPath })); + return; + } + } catch {} + } + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "Desk not found" })); + return; + } + + const signals = await scanSignals(workshopDir); + const stashed = await readStash(workshopDir); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderDashboard(signals, stashed)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + return { server, url: `http://127.0.0.1:${port}/` }; +} + +// --- Canvas registration --- + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "signals-dashboard", + displayName: "Workshop Signals", + description: "Live dashboard showing agent signals from workshop desks. Pass workshopDir to point at your workshop root.", + inputSchema: { + type: "object", + properties: { + workshopDir: { type: "string", description: "Absolute path to the workshop root (the folder containing desks/)" }, + }, + required: ["workshopDir"], + }, + actions: [ + { + name: "refresh", + description: "Force-refresh the signals dashboard and return current signal data as JSON", + handler: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) return { error: "Dashboard not open" }; + const signals = await scanSignals(entry.workshopDir); + const stashed = await readStash(entry.workshopDir); + return { signals, stashed, activeCount: signals.length - stashed.length }; + }, + }, + { + name: "stash", + description: "Stash a desk (hides it for 48hrs, then it drops off). Use to pause a workstream.", + inputSchema: { + type: "object", + properties: { deskName: { type: "string", description: "Name of the desk to stash" } }, + required: ["deskName"], + }, + handler: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) return { error: "Dashboard not open" }; + const stash = await stashDesk(entry.workshopDir, ctx.input.deskName); + return { ok: true, stashed: stash }; + }, + }, + { + name: "restore", + description: "Restore a stashed desk back to active.", + inputSchema: { + type: "object", + properties: { deskName: { type: "string", description: "Name of the desk to restore" } }, + required: ["deskName"], + }, + handler: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) return { error: "Dashboard not open" }; + const stash = await restoreDesk(entry.workshopDir, ctx.input.deskName); + return { ok: true, stashed: stash }; + }, + }, + { + name: "open_desk", + description: "Open a desk as a new session in the GHCP app. Returns the desk path so the agent can create_session or navigate to it.", + inputSchema: { + type: "object", + properties: { deskName: { type: "string", description: "Name of the desk to open" } }, + required: ["deskName"], + }, + handler: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) return { error: "Dashboard not open" }; + // Check both desks/ and classroom/ + for (const subdir of ["desks", "classroom"]) { + const deskPath = join(entry.workshopDir, subdir, ctx.input.deskName); + try { + const s = await stat(deskPath); + if (s.isDirectory()) { + return { ok: true, deskName: ctx.input.deskName, deskPath, workshopDir: entry.workshopDir }; + } + } catch {} + } + return { error: `Desk '${ctx.input.deskName}' not found` }; + }, + }, + ], + open: async (ctx) => { + const workshopDir = ctx.input?.workshopDir || process.cwd(); + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(ctx.instanceId, workshopDir); + entry.workshopDir = workshopDir; + servers.set(ctx.instanceId, entry); + } + return { title: "🪨 Cairn · Signals", url: entry.url }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + await new Promise((resolve) => entry.server.close(() => resolve())); + } + }, + }), + ], +}); diff --git a/plugins/the-workshop/.github/plugin/plugin.json b/plugins/the-workshop/.github/plugin/plugin.json index a43b4451..fbe149f0 100644 --- a/plugins/the-workshop/.github/plugin/plugin.json +++ b/plugins/the-workshop/.github/plugin/plugin.json @@ -24,5 +24,10 @@ "./skills/desk-open/", "./skills/signal-write/", "./skills/workshop-create/" - ] + ], + "x-awesome-copilot": { + "extensions": [ + "./extensions/signals-dashboard/" + ] + } }