export type TodoStatus = "pending" | "in_progress" | "blocked" | "completed"; export interface TodoNode { id: string; parentId?: string; text: string; status: TodoStatus; } export interface TodoObserverState { version: 1; todos: TodoNode[]; updatedAt: string; observedTurn?: string; } export type ObserverStatus = | { kind: "idle"; message?: string } | { kind: "observing"; message?: string } | { kind: "error"; message: string }; export const TODO_STATE_ENTRY = "todo-observer-state"; const VALID_STATUSES = new Set(["pending", "in_progress", "blocked", "completed"]); function cleanId(value: unknown, fallback: string): string { if (typeof value !== "string") return fallback; const cleaned = value .trim() .replace(/[^a-zA-Z0-9._:-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 64); return cleaned || fallback; } function cleanText(value: unknown): string { if (typeof value !== "string") return ""; return value.replace(/\s+/g, " ").trim().slice(0, 500); } /** Normalize untrusted observer output and remove invalid parent links/cycles. */ export function normalizeTodos(input: readonly TodoNode[] | unknown, maxTodos = 100): TodoNode[] { if (!Array.isArray(input)) return []; const rawItems = input.slice(0, Math.max(1, maxTodos)) as Array>; const usedIds = new Set(); const firstNormalizedIdByRawId = new Map(); const pendingParents: Array = []; const todos: TodoNode[] = []; for (let index = 0; index < rawItems.length; index++) { const raw = rawItems[index] ?? {}; const text = cleanText(raw.text); if (!text) continue; const rawId = typeof raw.id === "string" ? raw.id.trim() : ""; const baseId = cleanId(raw.id, `todo-${index + 1}`); let id = baseId; let suffix = 2; while (usedIds.has(id)) id = `${baseId}-${suffix++}`; usedIds.add(id); if (rawId && !firstNormalizedIdByRawId.has(rawId)) firstNormalizedIdByRawId.set(rawId, id); const status = VALID_STATUSES.has(raw.status as TodoStatus) ? (raw.status as TodoStatus) : "pending"; todos.push({ id, text, status }); pendingParents.push(typeof raw.parentId === "string" ? raw.parentId.trim() : undefined); } const byId = new Map(todos.map((todo) => [todo.id, todo])); for (let index = 0; index < todos.length; index++) { const rawParent = pendingParents[index]; if (!rawParent) continue; const parentId = firstNormalizedIdByRawId.get(rawParent) ?? cleanId(rawParent, ""); if (parentId && parentId !== todos[index]!.id && byId.has(parentId)) todos[index]!.parentId = parentId; } // Break cycles defensively. Orphans were already promoted to roots above. for (const todo of todos) { const seen = new Set([todo.id]); let cursor = todo.parentId; while (cursor) { if (seen.has(cursor)) { delete todo.parentId; break; } seen.add(cursor); cursor = byId.get(cursor)?.parentId; } } return todos; }