+
+
+
+
diff --git a/apps/planning-demo/package.json b/apps/planning-demo/package.json
new file mode 100644
index 0000000..357c273
--- /dev/null
+++ b/apps/planning-demo/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "forgewisp-planning-demo",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint --config ../../eslint.config.mjs src tests",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@forgewisp/core": "workspace:*",
+ "@forgewisp/bundled-tools": "workspace:*",
+ "dompurify": "^3.2.0",
+ "marked": "^18.0.5"
+ },
+ "devDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "jsdom": "^25.0.0",
+ "typescript": "^5.4.0",
+ "vite": "^5.4.0",
+ "vitest": "^1.6.0"
+ }
+}
diff --git a/apps/planning-demo/src/main.ts b/apps/planning-demo/src/main.ts
new file mode 100644
index 0000000..90d8b67
--- /dev/null
+++ b/apps/planning-demo/src/main.ts
@@ -0,0 +1,546 @@
+import { createAgent, defineToolSet } from '@forgewisp/core';
+import type {
+ AgentResult,
+ AuditEvent,
+ ChatMessage,
+ PendingCall,
+ ForgewispConfig,
+} from '@forgewisp/core';
+import {
+ getCurrentTime,
+ generateUuid,
+ downloadFile,
+ PLANNING_TOOLS,
+} from '@forgewisp/bundled-tools';
+import {
+ renderArgsHtml,
+ renderArtifact,
+ renderAuditDetail,
+ renderMarkdown,
+ renderToolsList,
+ escapeHtml,
+} from './render.js';
+import { PlanBoard } from './plan-board.js';
+
+// ─── Sanitization note ────────────────────────────────────────────────────────
+// Every sink that turns model- or user-adjacent text into HTML goes through
+// DOMPurify (via the helpers in render.ts). The model can return arbitrary
+// markdown (including raw HTML); without sanitization,
+// `` would execute in the page.
+
+// ─── Cached DOM refs ──────────────────────────────────────────────────────────
+
+interface Elements {
+ toolsList: HTMLDivElement;
+ artifactsList: HTMLUListElement;
+ clearArtifactsBtn: HTMLButtonElement;
+ chatMessages: HTMLDivElement;
+ chatForm: HTMLFormElement;
+ chatInput: HTMLInputElement;
+ sendButton: HTMLButtonElement;
+ examplePrompts: HTMLDivElement;
+ reasoningSection: HTMLElement;
+ reasoningOutput: HTMLDivElement;
+ auditLog: HTMLUListElement;
+ clearAuditBtn: HTMLButtonElement;
+ configOverlay: HTMLDivElement;
+ configForm: HTMLFormElement;
+ configEndpoint: HTMLInputElement;
+ configModel: HTMLInputElement;
+ configApikey: HTMLInputElement;
+ confirmOverlay: HTMLDivElement;
+ confirmTitle: HTMLHeadingElement;
+ confirmDescription: HTMLParagraphElement;
+ confirmArgs: HTMLDivElement;
+ confirmAccept: HTMLButtonElement;
+ confirmReject: HTMLButtonElement;
+}
+
+function getEl(id: string): T {
+ const el = document.getElementById(id);
+ if (!el) throw new Error(`[planning-demo] Missing element #${id}`);
+ return el as T;
+}
+
+const els: Elements = {
+ toolsList: getEl('tools-list'),
+ artifactsList: getEl('artifacts-list'),
+ clearArtifactsBtn: getEl('clear-artifacts-btn'),
+ chatMessages: getEl('chat-messages'),
+ chatForm: getEl('chat-form'),
+ chatInput: getEl('chat-input'),
+ sendButton: getEl('chat-form').querySelector(
+ 'button[type="submit"]',
+ ) as HTMLButtonElement,
+ examplePrompts: getEl('example-prompts'),
+ reasoningSection: getEl('reasoning-section'),
+ reasoningOutput: getEl('reasoning-output'),
+ auditLog: getEl('audit-log'),
+ clearAuditBtn: getEl('clear-audit-btn'),
+ configOverlay: getEl('config-overlay'),
+ configForm: getEl('config-form'),
+ configEndpoint: getEl('config-endpoint'),
+ configModel: getEl('config-model'),
+ configApikey: getEl('config-apikey'),
+ confirmOverlay: getEl('confirm-overlay'),
+ confirmTitle: getEl('confirm-title'),
+ confirmDescription: getEl('confirm-description'),
+ confirmArgs: getEl('confirm-args'),
+ confirmAccept: getEl('confirm-accept'),
+ confirmReject: getEl('confirm-reject'),
+};
+
+// Derived, in-place view of the plans the agent tracks. Fed by audit events
+// (see plan-board.ts); the agent owns the authoritative state in localStorage.
+const board = new PlanBoard(els.artifactsList);
+
+// ─── Streaming output helpers ─────────────────────────────────────────────────
+
+function getOrCreateStreamingMessage(): HTMLDivElement {
+ let el = document.getElementById('streaming-message') as HTMLDivElement | null;
+ if (!el) {
+ // The first text token swaps the "Thinking…" placeholder out for the real
+ // streaming bubble.
+ removeThinkingPlaceholder();
+ el = document.createElement('div');
+ el.id = 'streaming-message';
+ el.className = 'message message-assistant streaming';
+ els.chatMessages.appendChild(el);
+ }
+ return el;
+}
+
+// "Thinking…" placeholder shown in the chat area between submit and the first
+// streamed text token. Lives only for the current turn.
+let currentTurnThinkingEl: HTMLDivElement | null = null;
+
+function showThinkingPlaceholder(): void {
+ removeThinkingPlaceholder();
+ const el = document.createElement('div');
+ el.className = 'message message-assistant thinking-indicator';
+ el.setAttribute('aria-label', 'Thinking');
+ el.appendChild(document.createElement('span')).className = 'dot';
+ el.appendChild(document.createElement('span')).className = 'dot';
+ el.appendChild(document.createElement('span')).className = 'dot';
+ els.chatMessages.appendChild(el);
+ currentTurnThinkingEl = el;
+}
+
+function removeThinkingPlaceholder(): void {
+ if (currentTurnThinkingEl) {
+ currentTurnThinkingEl.remove();
+ currentTurnThinkingEl = null;
+ }
+}
+
+function finalizeStreamingMessage(): HTMLDivElement | null {
+ const el = document.getElementById('streaming-message') as HTMLDivElement | null;
+ if (el) {
+ el.id = '';
+ el.classList.remove('streaming');
+ }
+ return el;
+}
+
+// ─── Confirmation dialog ──────────────────────────────────────────────────────
+
+// The core executor calls onConfirmRequired once per write/destructive tool
+// call, concurrently (Promise.allSettled over every call in a round). The UI
+// can only show one modal at a time, so we serialize the prompts with a FIFO
+// queue: each enqueued call resolves its own promise when the user answers its
+// dialog, then the next queued call is shown. This keeps the core's per-call
+// contract intact while ensuring no confirmation is silently auto-rejected.
+interface QueuedConfirm {
+ pendingCall: PendingCall;
+ resolve: (result: boolean) => void;
+}
+
+const confirmQueue: QueuedConfirm[] = [];
+let activeConfirm: QueuedConfirm | null = null;
+
+function showConfirmDialog(pendingCall: PendingCall): Promise {
+ return new Promise((resolve) => {
+ confirmQueue.push({ pendingCall, resolve });
+ processNextConfirm();
+ });
+}
+
+function processNextConfirm(): void {
+ if (activeConfirm) return; // a dialog is already open; it'll drain the queue
+ const next = confirmQueue.shift();
+ if (!next) return;
+ activeConfirm = next;
+ renderConfirmDialog(next.pendingCall, (result) => {
+ activeConfirm = null;
+ next.resolve(result);
+ processNextConfirm();
+ });
+}
+
+function renderConfirmDialog(pendingCall: PendingCall, done: (result: boolean) => void): void {
+ els.confirmTitle.textContent =
+ pendingCall.riskTier === 'destructive' ? '⚠️ Destructive Action' : 'Action Required';
+ els.confirmDescription.textContent = `Function: ${pendingCall.functionName}`;
+ // Args came through AJV validation, but escape defensively — never raw.
+ els.confirmArgs.innerHTML = renderArgsHtml(pendingCall.args);
+
+ els.confirmOverlay.classList.remove('hidden');
+ els.confirmAccept.focus();
+
+ const previouslyFocused = document.activeElement as HTMLElement | null;
+
+ const cleanup = (result: boolean): void => {
+ els.confirmOverlay.classList.add('hidden');
+ els.confirmAccept.removeEventListener('click', onAccept);
+ els.confirmReject.removeEventListener('click', onReject);
+ document.removeEventListener('keydown', onKeydown);
+ previouslyFocused?.focus?.();
+ done(result);
+ };
+ const onAccept = (): void => cleanup(true);
+ const onReject = (): void => cleanup(false);
+ const onKeydown = (e: KeyboardEvent): void => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ cleanup(false);
+ }
+ const target = e.target as Element | null;
+ if (e.key === 'Enter' && !(target instanceof HTMLTextAreaElement)) {
+ e.preventDefault();
+ cleanup(true);
+ }
+ };
+ els.confirmAccept.addEventListener('click', onAccept);
+ els.confirmReject.addEventListener('click', onReject);
+ document.addEventListener('keydown', onKeydown);
+}
+
+// ─── Audit log + artifacts ────────────────────────────────────────────────────
+
+const EVENT_LABELS: Record = {
+ function_requested: 'requested',
+ validation_passed: 'validation passed',
+ validation_failed: 'validation failed',
+ confirmation_requested: 'confirm?',
+ confirmation_accepted: 'confirmed',
+ confirmation_rejected: 'rejected',
+ function_executed: 'executed',
+ function_errored: 'errored',
+ audit_callback_errored: 'audit callback errored',
+ max_tool_rounds_reached: 'max rounds',
+ stream_malformed: 'stream malformed',
+};
+
+function appendAuditEntry(event: AuditEvent): void {
+ const li = document.createElement('li');
+ li.className = `audit-event audit-${event.type.replace(/_/g, '-')}`;
+ const label = EVENT_LABELS[event.type] ?? event.type;
+ li.innerHTML =
+ `${escapeHtml(event.functionName)}` +
+ `${escapeHtml(label)}` +
+ `${renderAuditDetail(event)}`;
+ els.auditLog.prepend(li);
+}
+
+function onAuditEvent(event: AuditEvent): void {
+ appendAuditEntry(event);
+ // Plan `function_executed` events update the live, in-place plan cards.
+ board.applyEvent(event);
+ // `function_errored` (and only it) renders an append-only error card.
+ const errHtml = renderArtifact(event);
+ if (errHtml) {
+ const li = document.createElement('li');
+ li.className = 'artifact artifact-error';
+ li.innerHTML = errHtml;
+ els.artifactsList.prepend(li);
+ }
+}
+
+function clearAuditUI(): void {
+ els.auditLog.innerHTML = '';
+}
+
+els.clearAuditBtn.addEventListener('click', () => {
+ if (!agent) return;
+ agent.clearAuditLog();
+ clearAuditUI();
+});
+
+els.clearArtifactsBtn.addEventListener('click', () => {
+ board.clear();
+});
+
+// ─── Chat UI ──────────────────────────────────────────────────────────────────
+
+function appendUserMessage(text: string): void {
+ const el = document.createElement('div');
+ el.className = 'message message-user';
+ el.textContent = text;
+ els.chatMessages.appendChild(el);
+}
+
+function appendAssistantMessage(text: string): void {
+ const el = document.createElement('div');
+ el.className = 'message message-assistant';
+ el.innerHTML = renderMarkdown(text);
+ els.chatMessages.appendChild(el);
+}
+
+// ─── Agent setup ──────────────────────────────────────────────────────────────
+
+type Agent = ReturnType;
+let agent: Agent | null = null;
+let inFlightController: AbortController | null = null;
+
+// Conversation history threaded back into agent.run so the model sees prior
+// user/assistant turns. Cleared whenever the agent is rebuilt (new config).
+const conversation: ChatMessage[] = [];
+
+interface AgentConfig {
+ endpoint: string;
+ apiKey: string;
+ model: string;
+}
+
+function buildAgent(cfg: AgentConfig): void {
+ // Abort any in-flight run from the previous agent before swapping it out.
+ if (inFlightController) {
+ inFlightController.abort();
+ inFlightController = null;
+ }
+
+ // A new agent means a fresh conversation — don't leak prior turns (which may
+ // have been produced by a different model/endpoint) into the new session.
+ conversation.length = 0;
+ // The live plan cards are a derived view of the prior agent's session; a new
+ // agent rehydrates them from listPlans/getPlan as it resumes, so drop stale ones.
+ board.clear();
+
+ const config: ForgewispConfig = {
+ llmEndpoint: cfg.endpoint,
+ apiKey: cfg.apiKey || undefined,
+ model: cfg.model,
+ systemPrompt:
+ 'You are a planning agent that breaks large requests into concrete steps and tracks them ' +
+ 'to completion using plan tools. For any request with 2+ steps, call createPlan up front ' +
+ 'with a short title and the 3-8 steps you foresee — one item per distinct step, merging ' +
+ 'trivial substeps. Keep one active plan per task; do not create a second plan for the same ' +
+ 'task. Work the plan in order: set the item to "in_progress" via updatePlanItem when you ' +
+ 'start it and "done" when you complete it, adding a short notes line about what you found ' +
+ 'or decided, and prefer one item in_progress at a time. If scope changes, re-plan with ' +
+ 'addPlanItem/removePlanItem rather than starting over. Call getPlan to re-read the full ' +
+ 'plan before editing if unsure of current state, and listPlans at the start of a turn to ' +
+ 'resume an in-progress plan. Prefer removePlanItem over deleting a whole plan; once every ' +
+ 'item is "done" and the task is complete, call deletePlan to tear down the finished plan ' +
+ 'before giving your final summary. Use getCurrentTime when scheduling or deadlines matter. ' +
+ 'Narrate briefly in chat as you complete each step, and give a one-line summary when the ' +
+ 'plan is done.',
+ // Planning turns fan out across many tool calls (createPlan + per-item
+ // in_progress/done updates + listPlans/getPlan reads), so lift the cap well
+ // above the default 10 — an 8-step plan already needs ~20 rounds.
+ maxToolRounds: 40,
+ // All registered tools are read-tier, so onConfirmRequired is never invoked. The
+ // confirm wiring is retained as a reference for consumers who later add write/destructive tools.
+ onConfirmRequired: showConfirmDialog,
+ onAuditEvent,
+ streaming: {
+ reasoning: { mode: 'native' },
+ onTextChunk: (chunk: string) => {
+ const acc = currentTurnStreamingText;
+ if (acc === null) return;
+ acc.text += chunk;
+ const el = getOrCreateStreamingMessage();
+ el.innerHTML = renderMarkdown(acc.text);
+ },
+ onReasoningChunk: (chunk: string) => {
+ els.reasoningSection.classList.remove('hidden');
+ els.reasoningOutput.textContent += chunk;
+ },
+ },
+ };
+
+ agent = createAgent(config);
+ registerTools(agent);
+}
+
+interface TurnStreamingState {
+ text: string;
+}
+let currentTurnStreamingText: TurnStreamingState | null = null;
+
+// ─── Tool registration ────────────────────────────────────────────────────────
+
+// Extras registered alongside the planning set: getCurrentTime/generateUuid for
+// general use, downloadFile (write-tier) as the example task's final step —
+// triggers the confirm flow rendered from schema-validated args. Grouped as a
+// ToolSet so registration is a single call and the heterogeneous-args tuple
+// needs no `as unknown as` cast (defineToolSet erases via FunctionDefinition).
+const EXTRA_TOOLS = defineToolSet({
+ name: 'planning-extras',
+ description: 'Time/UUID helpers plus file download for the example task.',
+ tools: [getCurrentTime, generateUuid, downloadFile],
+});
+
+// The full toolkit surfaced in the sidebar: the planning set plus the extras.
+const SIDEBAR_TOOLS = [...PLANNING_TOOLS.tools, ...EXTRA_TOOLS.tools];
+
+function registerTools(a: Agent): void {
+ // Register the 7 plan-management tools plus the extras, each set in one call.
+ a.registerToolSet(PLANNING_TOOLS);
+ a.registerToolSet(EXTRA_TOOLS);
+}
+
+// ─── Config overlay ───────────────────────────────────────────────────────────
+
+function showConfigForm(): void {
+ els.configOverlay.classList.remove('hidden');
+ els.configEndpoint.focus();
+}
+
+function hideConfigForm(): void {
+ els.configOverlay.classList.add('hidden');
+}
+
+// ─── Chat form ────────────────────────────────────────────────────────────────
+
+function setFormDisabled(disabled: boolean): void {
+ els.chatInput.disabled = disabled;
+ els.sendButton.disabled = disabled;
+ els.examplePrompts.querySelectorAll('button.example-prompt').forEach((b) => {
+ b.disabled = disabled;
+ });
+}
+
+async function handleChatSubmit(e: SubmitEvent): Promise {
+ e.preventDefault();
+ const text = els.chatInput.value.trim();
+ if (!text || !agent) return;
+ if (inFlightController) return; // race guard — already a run in flight
+
+ appendUserMessage(text);
+ els.chatInput.value = '';
+ setFormDisabled(true);
+ showThinkingPlaceholder();
+
+ // Per-turn streaming buffer; not module-level, so concurrent turns can't
+ // cross-pollinate (and rebuilds don't inherit stale state).
+ currentTurnStreamingText = { text: '' };
+ els.reasoningOutput.textContent = '';
+ els.reasoningSection.classList.add('hidden');
+
+ const controller = new AbortController();
+ inFlightController = controller;
+
+ try {
+ // `history` is the prior turns only — the current `text` is passed as the
+ // userMessage arg. We append both turns to `conversation` only after the
+ // run succeeds, so a failed/aborted exchange never pollutes future history.
+ const result: AgentResult = await agent.run(text, {
+ signal: controller.signal,
+ history: conversation,
+ });
+ const streamingEl = finalizeStreamingMessage();
+ if (result.response) {
+ conversation.push({ role: 'user', content: text });
+ conversation.push({ role: 'assistant', content: result.response });
+ }
+ // If the response was already rendered via streaming chunks, don't duplicate it.
+ if (result.response && !streamingEl) {
+ appendAssistantMessage(result.response);
+ }
+ } catch (err) {
+ finalizeStreamingMessage();
+ const msg = err instanceof Error ? err.message : String(err);
+ appendAssistantMessage(`[error] ${msg}`);
+ } finally {
+ inFlightController = null;
+ currentTurnStreamingText = null;
+ removeThinkingPlaceholder();
+ setFormDisabled(false);
+ els.chatInput.focus();
+ }
+}
+
+els.chatForm.addEventListener('submit', (e: SubmitEvent) => void handleChatSubmit(e));
+
+// ─── Config form handler ──────────────────────────────────────────────────────
+
+els.configForm.addEventListener('submit', (e: SubmitEvent) => {
+ e.preventDefault();
+ const cfg: AgentConfig = {
+ endpoint: els.configEndpoint.value.trim(),
+ model: els.configModel.value.trim(),
+ apiKey: els.configApikey.value.trim(),
+ };
+ if (!cfg.endpoint || !cfg.model) return;
+ localStorage.setItem('forgewisp.planning-demo.config', JSON.stringify(cfg));
+ buildAgent(cfg);
+ hideConfigForm();
+});
+
+// ─── Safe localStorage config ─────────────────────────────────────────────────
+
+function isAgentConfig(v: unknown): v is AgentConfig {
+ if (typeof v !== 'object' || v === null) return false;
+ const o = v as Record;
+ return (
+ typeof o.endpoint === 'string' &&
+ typeof o.model === 'string' &&
+ (o.apiKey === undefined || typeof o.apiKey === 'string')
+ );
+}
+
+function loadStoredConfig(): AgentConfig | null {
+ const stored = localStorage.getItem('forgewisp.planning-demo.config');
+ if (!stored) return null;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(stored);
+ } catch {
+ return null;
+ }
+ if (!isAgentConfig(parsed)) {
+ // Corrupt or shape-mismatched — clear it so the user gets a clean form.
+ localStorage.removeItem('forgewisp.planning-demo.config');
+ return null;
+ }
+ return parsed;
+}
+
+// ─── Example prompt chips ──────────────────────────────────────────────────────
+
+// One-click "large task" prompts so visitors immediately see the agent
+// decompose a concrete multi-step request. Text is set via textContent (no
+// parsing), so the prompts are safe even if edited to include markup.
+const EXAMPLE_PROMPTS = [
+ 'Write a product launch announcement document with an intro, key features, pricing, and a call to action. Download it as a Markdown file as the last step.',
+];
+
+function renderExamplePrompts(): void {
+ els.examplePrompts.innerHTML = '';
+ for (const prompt of EXAMPLE_PROMPTS) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'example-prompt';
+ btn.textContent = prompt;
+ btn.addEventListener('click', () => {
+ // The race guard in handleChatSubmit covers a click during an in-flight run.
+ if (inFlightController) return;
+ els.chatInput.value = prompt;
+ els.chatForm.dispatchEvent(new SubmitEvent('submit', { cancelable: true, bubbles: true }));
+ });
+ els.examplePrompts.appendChild(btn);
+ }
+}
+
+// ─── Boot ─────────────────────────────────────────────────────────────────────
+
+els.toolsList.innerHTML = renderToolsList(SIDEBAR_TOOLS);
+renderExamplePrompts();
+
+const stored = loadStoredConfig();
+if (stored) {
+ buildAgent(stored);
+} else {
+ showConfigForm();
+}
diff --git a/apps/planning-demo/src/plan-board.ts b/apps/planning-demo/src/plan-board.ts
new file mode 100644
index 0000000..e4d298f
--- /dev/null
+++ b/apps/planning-demo/src/plan-board.ts
@@ -0,0 +1,183 @@
+import type { AuditEvent } from '@forgewisp/core';
+import type { Plan, PlanItem } from '@forgewisp/bundled-tools';
+import { renderLivePlanCard } from './render.js';
+
+// ─── PlanBoard ──────────────────────────────────────────────────────────────
+//
+// A derived, in-place view of the plans the agent is tracking. The agent owns
+// the authoritative state in localStorage (`forgewisp.plans` via plan-store); the
+// board does NOT read localStorage. Instead it reconstructs each plan from the
+// `function_executed` audit events, which carry the tool `result` (and `args`
+// for the planId/itemId the tool acted on):
+//
+// createPlan / getPlan → result.plan is the full Plan → replace the plan.
+// addPlanItem → result.item + args.planId → append the item.
+// updatePlanItem → result.item + args.planId → patch the item by id.
+// removePlanItem → args.planId + args.itemId → drop the item.
+// deletePlan → args.planId → remove the plan.
+// listPlans / others → no item data → no change.
+//
+// One persistent
card per plan, keyed by planId, is re-rendered in place
+// on each event (same DOM node) and moved to the top — so a multi-step task
+// shows a single evolving checklist (◻ → ◑ → ✓) rather than a stack of cards.
+// On reload the board is empty until the agent calls listPlans/getPlan to resume,
+// at which point the live card rehydrates from those events.
+
+function str(v: unknown): string {
+ return typeof v === 'string' ? v : '';
+}
+
+/** Coerce an unknown value to a `PlanItem` (field-by-field safe). */
+function asItem(v: unknown): PlanItem | null {
+ if (typeof v !== 'object' || v === null) return null;
+ const o = v as Record;
+ if (typeof o.id !== 'string' || typeof o.title !== 'string' || typeof o.status !== 'string') {
+ return null;
+ }
+ return o as unknown as PlanItem;
+}
+
+/** Coerce an unknown value to a `Plan` (field-by-field safe). */
+function asPlan(v: unknown): Plan | null {
+ if (typeof v !== 'object' || v === null) return null;
+ const o = v as Record;
+ if (typeof o.id !== 'string' || typeof o.title !== 'string' || !Array.isArray(o.items)) {
+ return null;
+ }
+ return o as unknown as Plan;
+}
+
+export class PlanBoard {
+ private readonly container: HTMLUListElement;
+ private readonly plans = new Map();
+ // Card element per planId, so updates re-render the SAME node in place.
+ private readonly cards = new Map();
+
+ constructor(container: HTMLUListElement) {
+ this.container = container;
+ }
+
+ /** Apply an audit event; only `function_executed` plan events update the board. */
+ applyEvent(event: AuditEvent): void {
+ if (event.type !== 'function_executed') return;
+ // `event.args` is already `Record | undefined`, so `?? {}`
+ // collapses to `Record` — no assertion needed. `result` is
+ // `unknown`, so it still needs the cast to be indexable.
+ const args: Record = event.args ?? {};
+ const result = (event.result ?? {}) as Record;
+
+ switch (event.functionName) {
+ case 'createPlan':
+ case 'getPlan': {
+ const plan = asPlan(result.plan);
+ if (plan) this.setPlan(plan);
+ break;
+ }
+ case 'addPlanItem': {
+ const planId = str(args.planId);
+ const item = asItem(result.item);
+ if (planId && item) this.addItem(planId, item);
+ break;
+ }
+ case 'updatePlanItem': {
+ const planId = str(args.planId);
+ const item = asItem(result.item);
+ if (planId && item) this.patchItem(planId, item);
+ break;
+ }
+ case 'removePlanItem': {
+ const planId = str(args.planId);
+ const itemId = str(args.itemId);
+ if (planId && itemId) this.removeItem(planId, itemId);
+ break;
+ }
+ case 'deletePlan': {
+ const planId = str(args.planId);
+ if (planId) this.removePlan(planId);
+ break;
+ }
+ default:
+ break; // listPlans, getCurrentTime, generateUuid — no board change
+ }
+ }
+
+ /** Drop all known plans and clear the panel (including any error cards). */
+ clear(): void {
+ this.plans.clear();
+ this.cards.clear();
+ this.container.innerHTML = '';
+ }
+
+ // ── internal ────────────────────────────────────────────────────────────
+
+ /** Replace (or seed) a plan and move its card to the top. */
+ private setPlan(plan: Plan): void {
+ this.plans.set(plan.id, plan);
+ this.renderCard(plan.id);
+ }
+
+ /** Append an item to a plan, seeding a stub if the plan wasn't seen in full. */
+ private addItem(planId: string, item: PlanItem): void {
+ const plan = this.getOrSeed(planId, item);
+ plan.items = plan.items.filter((i) => i.id !== item.id);
+ plan.items.push(item);
+ this.renderCard(planId);
+ }
+
+ /** Patch an item by id (or push it if new), seeding a stub plan if needed. */
+ private patchItem(planId: string, item: PlanItem): void {
+ const plan = this.getOrSeed(planId, item);
+ const idx = plan.items.findIndex((i) => i.id === item.id);
+ if (idx === -1) plan.items.push(item);
+ else plan.items[idx] = item;
+ this.renderCard(planId);
+ }
+
+ /** Remove an item from a plan (no-op if the plan is unknown). */
+ private removeItem(planId: string, itemId: string): void {
+ const plan = this.plans.get(planId);
+ if (!plan) return;
+ plan.items = plan.items.filter((i) => i.id !== itemId);
+ this.renderCard(planId);
+ }
+
+ /** Remove a plan and its card. */
+ private removePlan(planId: string): void {
+ this.plans.delete(planId);
+ const li = this.cards.get(planId);
+ if (li) {
+ li.remove();
+ this.cards.delete(planId);
+ }
+ }
+
+ /**
+ * Return the plan for planId, seeding a minimal stub if the board hasn't seen
+ * it in full yet (rare: the agent used listPlans → addPlanItem without a prior
+ * createPlan/getPlan). The stub self-corrects on the next getPlan/createPlan.
+ */
+ private getOrSeed(planId: string, item: PlanItem): Plan {
+ let plan = this.plans.get(planId);
+ if (!plan) {
+ plan = { id: planId, title: '(unsaved plan)', createdAt: item.createdAt, items: [] };
+ this.plans.set(planId, plan);
+ }
+ return plan;
+ }
+
+ /** Re-render a plan's card in place, creating it and moving it to top. */
+ private renderCard(planId: string): void {
+ const plan = this.plans.get(planId);
+ if (!plan) return;
+ let li = this.cards.get(planId);
+ if (!li) {
+ li = document.createElement('li');
+ li.className = 'artifact artifact-plan';
+ li.dataset.planId = planId;
+ this.cards.set(planId, li);
+ }
+ li.innerHTML = renderLivePlanCard(plan);
+ // Most recently touched plan on top.
+ this.container.prepend(li);
+ }
+}
diff --git a/apps/planning-demo/src/render.ts b/apps/planning-demo/src/render.ts
new file mode 100644
index 0000000..82a9f76
--- /dev/null
+++ b/apps/planning-demo/src/render.ts
@@ -0,0 +1,201 @@
+import DOMPurify from 'dompurify';
+import { marked } from 'marked';
+import type { AuditEvent, RiskTier } from '@forgewisp/core';
+import type { Plan, PlanItem, PlanStatus } from '@forgewisp/bundled-tools';
+
+marked.setOptions({ breaks: true, gfm: true });
+
+// Model-controlled markdown is untrusted: it may contain raw HTML. This allowlist
+// is deliberately tight (no , no ';
+
+function hasExecutablePayload(html: string): boolean {
+ const tpl = document.createElement('template');
+ tpl.innerHTML = html;
+ const hasOnerror = Array.from(tpl.content.querySelectorAll('*')).some((el) =>
+ el.hasAttribute('onerror'),
+ );
+ const hasScript = tpl.content.querySelector('script') !== null;
+ return hasOnerror || hasScript;
+}
+
+describe('sanitize — renderMarkdown (assistant message + streaming sinks)', () => {
+ it('strips onerror handlers from img tags', () => {
+ const out = renderMarkdown(XSS_IMG);
+ expect(out).not.toContain('onerror');
+ expect(hasExecutablePayload(out)).toBe(false);
+ });
+
+ it('strips script tags', () => {
+ const out = renderMarkdown(`${XSS_SCRIPT}\n\nHello`);
+ expect(out).not.toContain('