From 760516b2e7b97fa389fb3902e8d2314eea052ff0 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Sat, 2 May 2026 15:07:33 +0100
Subject: feat: multi-document directives with ephemeral task lifecycle (#119)

* feat: soryu-co/soryu - makima: Fix folder/file naming and breadcrumb hash bugs

* WIP: heartbeat checkpoint

* WIP: heartbeat checkpoint

* WIP: heartbeat checkpoint

* feat: soryu-co/soryu - makima: Frontend: render multiple documents per directive folder

* WIP: heartbeat checkpoint

* WIP: heartbeat checkpoint

* WIP: heartbeat checkpoint

* Fix DirectiveRevision import in openapi.rs after merge

* Fix document-directives.tsx merge artifacts and add inactive status
---
 makima/frontend/src/routes/document-directives.tsx | 2352 ++++++++------------
 1 file changed, 869 insertions(+), 1483 deletions(-)

(limited to 'makima/frontend/src/routes/document-directives.tsx')

diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index a3ea969..b583bef 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -3,36 +3,20 @@ import { useNavigate, useParams, useSearchParams } from "react-router";
 import { Masthead } from "../components/Masthead";
 import { useDirective, useDirectives } from "../hooks/useDirectives";
 import { useAuth } from "../contexts/AuthContext";
-import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";
 import { DocumentEditor } from "../components/directives/DocumentEditor";
-import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
-import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu";
 import {
-  startDirective,
-  pauseDirective,
-  updateDirective,
-  deleteDirective,
-  completeDirectiveStep,
-  failDirectiveStep,
-  skipDirectiveStep,
-  stopTask,
-  listDirectiveRevisions,
-  newDirectiveDraft,
-  createDirectivePR,
-  advanceDirective,
-  cleanupDirective,
-  pickUpOrders,
-  sendTaskMessage,
-  listDirectiveEphemeralTasks,
-  createDirectiveTask,
-  listOrphanTasks,
-} from "../lib/api";
-import type {
-  DirectiveStatus,
-  DirectiveSummary,
-  DirectiveWithSteps,
-  DirectiveRevision,
-  TaskSummary,
+  type DirectiveSummary,
+  type DirectiveStatus,
+  type DirectiveDocument,
+  type DirectiveDocumentStatus,
+  type DirectiveStep,
+  type Task,
+  type DocumentTasksResponse,
+  listDirectiveDocuments,
+  createDirectiveDocument,
+  getDirectiveDocument,
+  updateDirectiveDocument,
+  listDirectiveDocumentTasks,
 } from "../lib/api";
 
 // Status dot color, matching the existing tabular UI's badge palette so the
@@ -46,17 +30,52 @@ const STATUS_DOT: Record<DirectiveStatus, string> = {
   archived: "bg-[#3a4a6a]",
 };
 
-// Per-task dot color for the sidebar entries inside a directive folder.
-// Matches the StepsBlockNode palette.
-const STEP_STATUS_DOT: Record<string, string> = {
-  pending: "bg-[#556677]",
-  ready: "bg-[#9bc3ff]",
-  running: "bg-yellow-400",
-  done: "bg-green-400",
-  failed: "bg-red-400",
-  skipped: "bg-[#3a4a6a]",
+// Per-document status palette. Active/draft documents use the same bright
+// green-ish accent as a running directive; shipped/archived use a muted blue.
+const DOC_STATUS_DOT: Record<DirectiveDocumentStatus, string> = {
+  draft: "bg-[#556677]",
+  active: "bg-green-400",
+  shipped: "bg-[#75aafc]",
+  archived: "bg-[#3a4a6a]",
+};
+
+// =============================================================================
+// Sidebar grouping — group directives by lifecycle stage so the file tree
+// reads like a folder per status. We collapse the noisy ones (Archived) by
+// default and keep Active / Idle expanded.
+// =============================================================================
+
+type SidebarGroup = "active" | "idle" | "archived";
+
+const GROUP_LABEL: Record<SidebarGroup, string> = {
+  active: "active",
+  idle: "idle",
+  archived: "archived",
 };
 
+function bucketOf(status: DirectiveStatus): SidebarGroup {
+  if (status === "active" || status === "paused") return "active";
+  if (status === "archived") return "archived";
+  // draft + idle land in the idle bucket (i.e. "not currently running").
+  return "idle";
+}
+
+// Slugify a document title for the displayed `.md` filename, falling back to
+// the directive title and finally the document id slice when the title is
+// empty. Mirrors the file-naming fix from step 1 (use the user-readable label
+// rather than just an id slice). Accepts either a DirectiveSummary or a full
+// DirectiveWithSteps — only `title` is read.
+function fileLabel(
+  doc: DirectiveDocument,
+  directive: { title: string },
+): string {
+  const docTitle = doc.title.trim();
+  if (docTitle.length > 0) return docTitle;
+  const dirTitle = directive.title.trim();
+  if (dirTitle.length > 0) return dirTitle;
+  return doc.id.slice(0, 8);
+}
+
 // =============================================================================
 // Sidebar icons (inline SVG, no new deps)
 // =============================================================================
@@ -112,103 +131,6 @@ function FileIcon() {
   );
 }
 
-/** Terminal/prompt icon for orchestrator and step tasks. */
-function TaskIcon() {
-  return (
-    <svg
-      viewBox="0 0 16 16"
-      width={12}
-      height={12}
-      className="shrink-0"
-      aria-hidden
-    >
-      <rect x="1.5" y="3" width="13" height="10" rx="1" fill="none" stroke="#9bc3ff" strokeWidth="1" />
-      <path d="M3.5 6l2 2-2 2 M7 10h4" stroke="#9bc3ff" strokeWidth="1" fill="none" strokeLinecap="round" />
-    </svg>
-  );
-}
-
-/** Asterisk-on-terminal icon for ephemeral spinoff tasks — visually
-    distinct from the plain TaskIcon used for step-spawned execution tasks
-    so users can tell at a glance which tasks are part of the DAG vs which
-    are user-spun side quests. */
-function EphemeralTaskIcon() {
-  return (
-    <svg
-      viewBox="0 0 16 16"
-      width={12}
-      height={12}
-      className="shrink-0"
-      aria-hidden
-    >
-      <rect x="1.5" y="3" width="13" height="10" rx="1" fill="none" stroke="#c084fc" strokeWidth="1" />
-      <path d="M3.5 6l2 2-2 2 M7 10h4" stroke="#c084fc" strokeWidth="1" fill="none" strokeLinecap="round" />
-      <path d="M11 4l1 1m-1 0l1-1" stroke="#c084fc" strokeWidth="1" fill="none" />
-    </svg>
-  );
-}
-
-/** PR-bracket icon for the completion task. */
-function CompletionIcon() {
-  return (
-    <svg
-      viewBox="0 0 16 16"
-      width={12}
-      height={12}
-      className="shrink-0"
-      aria-hidden
-    >
-      <circle cx="4" cy="4" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
-      <circle cx="4" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
-      <circle cx="12" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
-      <path d="M4 5.4v5.2 M4 12h6.6 M12 4l0 6.6" stroke="#9bc3ff" strokeWidth="1" fill="none" />
-    </svg>
-  );
-}
-
-function PinIcon() {
-  return (
-    <svg
-      viewBox="0 0 16 16"
-      width={10}
-      height={10}
-      className="shrink-0"
-      aria-hidden
-    >
-      <path
-        d="M8 1.5l1.6 3.6 3.9.4-2.95 2.7.85 3.9L8 10.2 4.6 12.1l.85-3.9L2.5 5.5l3.9-.4z"
-        fill="#75aafc"
-        opacity="0.7"
-      />
-    </svg>
-  );
-}
-
-/** Tiny chip used for the inline directive-folder hover actions. */
-function FolderActionButton({
-  children,
-  title,
-  onClick,
-}: {
-  children: React.ReactNode;
-  title: string;
-  onClick: () => void;
-}) {
-  return (
-    <button
-      type="button"
-      title={title}
-      onClick={(e) => {
-        e.stopPropagation();
-        onClick();
-      }}
-      className="w-5 h-5 flex items-center justify-center text-[10px] text-[#7788aa] hover:text-white hover:bg-[rgba(117,170,252,0.15)] rounded transition-colors"
-    >
-      {children}
-    </button>
-  );
-}
-
 function Caret({ open }: { open: boolean }) {
   return (
     <svg
@@ -224,940 +146,652 @@ function Caret({ open }: { open: boolean }) {
 }
 
 // =============================================================================
-// Sidebar
-// =============================================================================
-
-// =============================================================================
-// Task row context menu — sits next to DirectiveContextMenu and offers the
-// task-level controls (interrupt for orchestrator/completion, complete/fail/
-// skip for step tasks).
+// SidebarSelection — exactly one of taskId/documentId is non-null. taskId is
+// reserved for a future "task selection" feature (we expose it in the URL
+// already so the param shape is stable). documentId picks one of the
+// directive's documents.
 // =============================================================================
 
-interface TaskContextMenuProps {
-  x: number;
-  y: number;
-  task: FolderTaskRow;
-  onClose: () => void;
-  onInterrupt: () => void;
-  onComplete?: () => void;
-  onFail?: () => void;
-  onSkip?: () => void;
-  /** Send a freeform message to the running task (same wire as the inline comment box). */
-  onSendMessage?: () => void;
-  /** Navigate to the standalone task page for full-screen control. */
-  onOpenInTaskPage?: () => void;
-}
-
-function TaskContextMenu({
-  x,
-  y,
-  task,
-  onClose,
-  onInterrupt,
-  onComplete,
-  onFail,
-  onSkip,
-  onSendMessage,
-  onOpenInTaskPage,
-}: TaskContextMenuProps) {
-  const ref = useRef<HTMLDivElement>(null);
-
-  useEffect(() => {
-    const click = (e: MouseEvent) => {
-      if (ref.current && !ref.current.contains(e.target as Node)) onClose();
-    };
-    const key = (e: KeyboardEvent) => {
-      if (e.key === "Escape") onClose();
-    };
-    document.addEventListener("mousedown", click);
-    document.addEventListener("keydown", key);
-    return () => {
-      document.removeEventListener("mousedown", click);
-      document.removeEventListener("keydown", key);
-    };
-  }, [onClose]);
-
-  useEffect(() => {
-    if (!ref.current) return;
-    const rect = ref.current.getBoundingClientRect();
-    if (rect.right > window.innerWidth) ref.current.style.left = `${x - rect.width}px`;
-    if (rect.bottom > window.innerHeight) ref.current.style.top = `${y - rect.height}px`;
-  }, [x, y]);
-
-  const item =
-    "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2";
-  const divider = "border-t border-[rgba(117,170,252,0.2)] my-1";
-
-  // Interrupt is meaningful for live tasks (orchestrator-active or running steps).
-  const showInterrupt =
-    task.kind === "orchestrator-active" ||
-    task.kind === "completion" ||
-    task.status === "running";
-  // Step lifecycle controls only apply to step tasks.
-  const isStep = task.kind === "step";
-  const showComplete = isStep && task.status !== "done";
-  const showFail = isStep && task.status !== "failed";
-  const showSkip = isStep && task.status !== "skipped";
-
-  return (
-    <div
-      ref={ref}
-      className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
-      style={{ left: x, top: y }}
-    >
-      <div className="px-3 py-1.5 text-[10px] font-mono text-[#556677] uppercase border-b border-[rgba(117,170,252,0.2)] truncate max-w-[220px]">
-        {task.kind === "orchestrator-active" ? "Orchestrator" : task.kind === "completion" ? "Completion" : task.label}
-      </div>
-      {showInterrupt && (
-        <button
-          className={item}
-          onClick={() => {
-            onInterrupt();
-            onClose();
-          }}
-        >
-          <span className="text-amber-300">⏹</span>
-          Interrupt
-        </button>
-      )}
-      {(showComplete || showFail || showSkip) && <div className={divider} />}
-      {showComplete && (
-        <button
-          className={item}
-          onClick={() => {
-            onComplete?.();
-            onClose();
-          }}
-        >
-          <span className="text-emerald-400">✓</span>
-          Mark complete
-        </button>
-      )}
-      {showFail && (
-        <button
-          className={item}
-          onClick={() => {
-            onFail?.();
-            onClose();
-          }}
-        >
-          <span className="text-red-400">✗</span>
-          Mark failed
-        </button>
-      )}
-      {showSkip && (
-        <button
-          className={item}
-          onClick={() => {
-            onSkip?.();
-            onClose();
-          }}
-        >
-          <span className="text-[#7788aa]">⤳</span>
-          Skip
-        </button>
-      )}
-
-      {/* Direct task-page actions: send-message and open-in-task-page mirror
-          what the standalone /exec/:taskId page exposes. */}
-      {(onSendMessage || onOpenInTaskPage) && <div className={divider} />}
-      {onSendMessage && (
-        <button
-          className={item}
-          onClick={() => {
-            onSendMessage();
-            onClose();
-          }}
-        >
-          <span className="text-cyan-300">⌨</span>
-          Send message
-        </button>
-      )}
-      {onOpenInTaskPage && (
-        <button
-          className={item}
-          onClick={() => {
-            onOpenInTaskPage();
-            onClose();
-          }}
-        >
-          <span className="text-[#75aafc]">↗</span>
-          Open in task page
-        </button>
-      )}
-    </div>
-  );
-}
-
-function slugify(title: string, fallback: string): string {
-  const slug = title
-    .trim()
-    .replace(/\s+/g, "-")
-    .replace(/[^a-zA-Z0-9._-]/g, "")
-    .toLowerCase();
-  return slug.length > 0 ? slug : fallback;
-}
-
 interface SidebarSelection {
   directiveId: string;
-  /** null = the directive's document; otherwise a task id (orchestrator/step). */
   taskId: string | null;
+  documentId: string | null;
+}
+
+// =============================================================================
+// Per-directive folder — renders as a collapsible folder containing the
+// directive's documents. Loads documents lazily on first open (mirroring the
+// pattern from step 1's DirectiveFolder, which fetched the full directive
+// only when expanded).
+// =============================================================================
+
+interface DirectiveFolderProps {
+  directive: DirectiveSummary;
+  open: boolean;
+  onToggle: () => void;
+  /** Called when the user clicks the folder header itself (after toggle). */
+  onHeaderClick: () => void;
+  selection: SidebarSelection | null;
+  onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void;
+  onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
+  /**
+   * Document refresh trigger — bumped externally so the folder refetches its
+   * document list after a create/update happens elsewhere. Primarily used so
+   * a freshly-created document shows up immediately.
+   */
+  refreshNonce: number;
 }
 
-/**
- * Per-directive folder. Renders the directive as a collapsible folder whose
- * body is the pinned document entry (always first) followed by a `tasks/`
- * subfolder containing the orchestrator, completion, and step tasks.
- *
- * Status dot lives on the right side only (single-side, per the v2 design).
- * If a directive or task has a pending user question, its icon glows.
- */
 function DirectiveFolder({
   directive,
   open,
   onToggle,
+  onHeaderClick,
   selection,
-  onSelect,
-  pendingTaskIds,
-  hasPendingForDirective,
-  onDirectiveContextMenu,
-  onTaskContextMenu,
-  onCreateTask,
-  onQuickAction,
-}: {
-  directive: DirectiveSummary;
-  open: boolean;
-  onToggle: () => void;
-  selection: SidebarSelection | null;
-  onSelect: (sel: SidebarSelection) => void;
-  /** Set of task ids that currently have pending user questions. */
-  pendingTaskIds: Set<string>;
-  /** Whether any pending question is associated with this directive. */
-  hasPendingForDirective: boolean;
-  onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void;
-  onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void;
-  /** Open the inline "+ New task" form for this directive. */
-  onCreateTask: (d: DirectiveSummary) => void;
-  /** Trigger a quick action (start/pause/PR) on the directive. */
-  onQuickAction: (d: DirectiveSummary, action: "start" | "pause" | "pr") => void;
-}) {
+  onSelectDocument,
+  onCreateDocument,
+  refreshNonce,
+}: DirectiveFolderProps) {
   const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft;
-  const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`;
+  const orchestratorRunning = !!directive.orchestratorTaskId;
 
-  // Lazy fetch full directive (with steps) only when folder is open.
-  const { directive: detailed } = useDirective(open ? directive.id : undefined);
+  // Documents fetched lazily on open. We deliberately scope the fetch to the
+  // open-state so closed folders don't pay the network cost on initial render.
+  const [docs, setDocs] = useState<DirectiveDocument[] | null>(null);
+  const [docsLoading, setDocsLoading] = useState(false);
+  const [docsError, setDocsError] = useState<string | null>(null);
 
-  const docSelected =
-    selection?.directiveId === directive.id && selection.taskId === null;
+  // shipped/ subfolder open state — independent of the directive folder.
+  const [shippedOpen, setShippedOpen] = useState(false);
 
-  // Ephemeral tasks attached to this directive (no directive_step_id). Fetched
-  // lazily when the folder opens; refetched whenever a poll lands on the
-  // directive's detail (poll-driven freshness).
-  const [ephemeralTasks, setEphemeralTasks] = useState<TaskSummary[]>([]);
-  useEffect(() => {
-    if (!open) return;
-    let cancelled = false;
-    listDirectiveEphemeralTasks(directive.id)
-      .then((res) => {
-        if (!cancelled) setEphemeralTasks(res.tasks);
-      })
-      .catch((err) => {
-        // eslint-disable-next-line no-console
-        console.warn("[makima] failed to load ephemeral tasks", err);
-      });
-    return () => {
-      cancelled = true;
-    };
-  }, [open, directive.id, directive.updatedAt]);
+  // Whether a "+ New document" call is in flight (disables the button).
+  const [creating, setCreating] = useState(false);
 
-  // Collect the tasks to surface in the folder body.
-  const tasks = useMemo(
-    () => collectTasks(detailed, directive, ephemeralTasks),
-    [detailed, directive, ephemeralTasks],
-  );
+  const refresh = useCallback(async () => {
+    setDocsLoading(true);
+    setDocsError(null);
+    try {
+      const list = await listDirectiveDocuments(directive.id);
+      setDocs(list);
+    } catch (e) {
+      setDocsError(e instanceof Error ? e.message : "Failed to load documents");
+    } finally {
+      setDocsLoading(false);
+    }
+  }, [directive.id]);
 
-  const orchestratorRunning = !!directive.orchestratorTaskId;
-  // Tasks subfolder open state — independent of the directive folder.
-  const [tasksOpen, setTasksOpen] = useState<boolean>(true);
-  // Revisions subfolder — collapsed by default since most contracts have
-  // no merged history yet.
-  const [revisionsOpen, setRevisionsOpen] = useState<boolean>(false);
-  const [revisions, setRevisions] = useState<DirectiveRevision[]>([]);
-  // Fetch revisions only when the parent folder is open. Re-fetch whenever
-  // the directive's pr_url changes so a freshly-raised PR appears.
+  // Fetch on open; refetch when refreshNonce bumps and the folder is open.
   useEffect(() => {
     if (!open) return;
-    let cancelled = false;
-    listDirectiveRevisions(directive.id)
-      .then((res) => {
-        if (!cancelled) setRevisions(res.revisions);
-      })
-      .catch((err) => {
-        // eslint-disable-next-line no-console
-        console.warn("[makima] failed to load revisions", err);
-      });
-    return () => {
-      cancelled = true;
-    };
-  }, [open, directive.id, directive.prUrl]);
-
-  // Inline action buttons on the folder header — visible on hover (and when
-  // the folder is open) so users don't have to right-click to discover the
-  // primary directive controls. Mirrors a code-editor sidebar's affordance.
-  const showStart =
-    directive.status === "draft" ||
-    directive.status === "paused" ||
-    directive.status === "idle" ||
-    directive.status === "inactive";
-  const showPause = directive.status === "active";
+    void refresh();
+  }, [open, refresh, refreshNonce]);
+
+  // Split the documents into the two visual groups. Memoised so we don't
+  // recompute on every render.
+  const { activeDocs, shippedDocs } = useMemo(() => {
+    const active: DirectiveDocument[] = [];
+    const shipped: DirectiveDocument[] = [];
+    for (const d of docs ?? []) {
+      if (d.status === "shipped" || d.status === "archived") {
+        shipped.push(d);
+      } else {
+        active.push(d);
+      }
+    }
+    // Stable order: by createdAt ascending so the first row is the oldest doc.
+    active.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
+    shipped.sort((a, b) => (b.shippedAt ?? "").localeCompare(a.shippedAt ?? ""));
+    return { activeDocs: active, shippedDocs: shipped };
+  }, [docs]);
+
+  const handleCreate = useCallback(async () => {
+    if (creating) return;
+    setCreating(true);
+    try {
+      await onCreateDocument(directive);
+      // Refresh after creating so the new doc appears in the list.
+      await refresh();
+    } finally {
+      setCreating(false);
+    }
+  }, [creating, onCreateDocument, directive, refresh]);
+
+  // Selection helpers — used to highlight the currently-selected doc row.
+  const selectedDocumentId =
+    selection && selection.directiveId === directive.id
+      ? selection.documentId
+      : null;
 
   return (
-    <div className="select-none group/dir">
-      <div
-        className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
-        onContextMenu={(e) => onDirectiveContextMenu(e, directive)}
+    <div className="select-none">
+      {/* Directive folder header */}
+      <button
+        type="button"
+        onClick={() => {
+          onToggle();
+          onHeaderClick();
+        }}
+        title={directive.title}
+        className="w-full flex items-center gap-1.5 pl-9 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)]"
       >
-        <button
-          type="button"
-          onClick={onToggle}
-          title={directive.title}
-          className="flex items-center gap-1.5 flex-1 min-w-0 text-left"
-        >
-          <Caret open={open} />
-          <FolderIcon open={open} />
-          <span className="truncate flex-1">{directive.title}</span>
-        </button>
-
-        {/* Hover/open-only action chips — discoverable replacement for the
-            right-click menu. Right-click still works as a power-user fallback. */}
-        <div
-          className={`flex items-center gap-0.5 transition-opacity ${
-            open
-              ? "opacity-100"
-              : "opacity-0 group-hover/dir:opacity-100"
-          }`}
-        >
-          {showStart && (
-            <FolderActionButton
-              title="Start"
-              onClick={() => onQuickAction(directive, "start")}
-            >
-              ▶
-            </FolderActionButton>
-          )}
-          {showPause && (
-            <FolderActionButton
-              title="Pause"
-              onClick={() => onQuickAction(directive, "pause")}
-            >
-              ❚❚
-            </FolderActionButton>
-          )}
-          {directive.prUrl && (
-            <FolderActionButton
-              title="Open PR"
-              onClick={() =>
-                window.open(directive.prUrl ?? "", "_blank", "noreferrer")
-              }
-            >
-              ↗
-            </FolderActionButton>
-          )}
-          <FolderActionButton
-            title="New task"
-            onClick={() => onCreateTask(directive)}
-          >
-            ＋
-          </FolderActionButton>
-        </div>
-
-        {/* Status dot — RIGHT side only. Glows when this directive has a
-            pending user question, or pulses when the orchestrator is live. */}
-        <StatusDot
-          color={dotColor}
-          live={orchestratorRunning}
-          glow={hasPendingForDirective}
-          status={directive.status}
+        <Caret open={open} />
+        <FolderIcon open={open} />
+        <span
+          className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`}
+          aria-hidden
         />
-      </div>
+        <span className="truncate flex-1 text-left">
+          {directive.title.trim().length > 0
+            ? directive.title
+            : directive.id.slice(0, 8)}
+          /
+        </span>
+        {orchestratorRunning && (
+          <span
+            className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse"
+            title="Orchestrator running"
+            aria-label="Orchestrator running"
+          />
+        )}
+      </button>
 
+      {/* Folder body — rendered only when open */}
       {open && (
-        <ul className="py-0.5">
-          {/* Pinned document entry — always at the top of the folder. */}
-          <li>
-            <button
-              type="button"
-              onClick={() =>
-                onSelect({ directiveId: directive.id, taskId: null })
-              }
-              className={`w-full text-left flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] transition-colors ${
-                docSelected
-                  ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
-                  : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
-              }`}
-            >
-              <PinIcon />
-              <FileIcon />
-              <span className="truncate flex-1">{fileName}</span>
-            </button>
-          </li>
-
-          {/* tasks/ subfolder — collapsible, contains orchestrator/completion/steps. */}
-          <li>
-            <button
-              type="button"
-              onClick={() => setTasksOpen((p) => !p)}
-              className="w-full flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
-            >
-              <Caret open={tasksOpen} />
-              <FolderIcon open={tasksOpen} />
-              <span className="truncate flex-1 text-left">tasks/</span>
-              {tasks.length > 0 && (
-                <span className="text-[10px] text-[#556677]">{tasks.length}</span>
-              )}
-            </button>
-
-            {tasksOpen && (
-              <ul className="py-0.5">
-                {tasks.length === 0 ? (
-                  <li className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677]">
-                    No tasks yet
-                  </li>
-                ) : (
-                  tasks.map((t) => {
-                    const isSelected =
-                      selection?.directiveId === directive.id &&
-                      selection?.taskId === t.taskId;
-                    const tdot = STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending;
-                    const live =
-                      t.status === "running" || t.kind === "orchestrator-active";
-                    const glow = pendingTaskIds.has(t.taskId);
-                    const Icon =
-                      t.kind === "completion"
-                        ? CompletionIcon
-                        : t.kind === "ephemeral"
-                        ? EphemeralTaskIcon
-                        : TaskIcon;
-                    return (
-                      <li key={t.taskId}>
-                        <button
-                          type="button"
-                          onClick={() =>
-                            onSelect({
-                              directiveId: directive.id,
-                              taskId: t.taskId,
-                            })
-                          }
-                          onContextMenu={(e) =>
-                            onTaskContextMenu(e, t, directive.id)
-                          }
-                          title={t.label}
-                          className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${
-                            isSelected
-                              ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
-                              : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
-                          }`}
-                        >
-                          <Icon />
-                          <span className="truncate flex-1">{t.label}</span>
-                          <StatusDot
-                            color={tdot}
-                            live={live}
-                            glow={glow}
-                            status={t.status}
-                          />
-                        </button>
-                      </li>
-                    );
-                  })
-                )}
-              </ul>
-            )}
-          </li>
+        <div className="py-0.5">
+          {docsLoading && !docs && (
+            <div className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677]">
+              Loading documents…
+            </div>
+          )}
+          {docsError && (
+            <div className="pl-14 pr-3 py-1 font-mono text-[10px] text-red-400">
+              {docsError}
+            </div>
+          )}
 
-          {/* revisions/ subfolder — per-PR frozen snapshots of this contract.
-              Only rendered when there's at least one revision; otherwise the
-              folder body would be a confusing empty placeholder. */}
-          {revisions.length > 0 && (
-            <li>
+          {/* Active group */}
+          {docs && (
+            <>
+              {/* + New document affordance — sits at the top of the active list
+                  so the user can always reach it without scrolling past
+                  existing docs. */}
               <button
                 type="button"
-                onClick={() => setRevisionsOpen((p) => !p)}
-                className="w-full flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
+                onClick={handleCreate}
+                disabled={creating}
+                className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-emerald-400 hover:bg-[rgba(74,222,128,0.06)] disabled:opacity-50"
+                title="Create a new document under this directive"
               >
-                <Caret open={revisionsOpen} />
-                <FolderIcon open={revisionsOpen} />
-                <span className="truncate flex-1 text-left">revisions/</span>
-                <span className="text-[10px] text-[#556677]">
-                  {revisions.length}
-                </span>
+                <span className="text-[12px] leading-none">+</span>
+                <span>New document</span>
               </button>
 
-              {revisionsOpen && (
-                <ul className="py-0.5">
-                  {revisions.map((r) => {
-                    const isSelected =
-                      selection?.directiveId === directive.id &&
-                      selection?.taskId === `revision:${r.id}`;
-                    return (
-                      <li key={r.id}>
-                        <button
-                          type="button"
-                          onClick={() =>
-                            onSelect({
-                              directiveId: directive.id,
-                              taskId: `revision:${r.id}`,
-                            })
-                          }
-                          title={`v${r.version} · ${r.prState} · ${r.prUrl}`}
-                          className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${
-                            isSelected
-                              ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
-                              : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
-                          }`}
-                        >
-                          <FileIcon />
-                          <span className="truncate flex-1">
-                            v{r.version}.md
-                          </span>
-                          <RevisionStateBadge prState={r.prState} />
-                        </button>
-                      </li>
-                    );
-                  })}
-                </ul>
+              {activeDocs.length === 0 && !docsLoading && (
+                <div className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677] italic">
+                  no active documents
+                </div>
+              )}
+
+              {activeDocs.map((doc) => (
+                // Each active document gets its own tasks/ subfolder
+                // immediately below it. Active docs default-open the
+                // folder so the user sees their live work without an
+                // extra click.
+                <div key={doc.id}>
+                  <DocumentRow
+                    doc={doc}
+                    directive={directive}
+                    selected={doc.id === selectedDocumentId}
+                    onSelect={() => onSelectDocument(directive.id, doc)}
+                  />
+                  <DocumentTasksFolder
+                    documentId={doc.id}
+                    depth="normal"
+                    defaultOpen={doc.status === "active"}
+                    refreshNonce={refreshNonce}
+                  />
+                </div>
+              ))}
+
+              {/* shipped/ subfolder — only rendered when at least one shipped
+                  or archived doc exists. Hidden entirely otherwise so empty
+                  directives stay tidy. */}
+              {shippedDocs.length > 0 && (
+                <div>
+                  <button
+                    type="button"
+                    onClick={() => setShippedOpen((v) => !v)}
+                    className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)]"
+                  >
+                    <Caret open={shippedOpen} />
+                    <FolderIcon open={shippedOpen} />
+                    <span>shipped/</span>
+                    <span className="ml-auto text-[10px] text-[#556677]">
+                      {shippedDocs.length}
+                    </span>
+                  </button>
+                  {shippedOpen &&
+                    shippedDocs.map((doc) => (
+                      // Shipped docs render the doc row + its frozen
+                      // tasks/ subfolder. The tasks/ folder defaults
+                      // closed (history) so it doesn't dominate the
+                      // sidebar; users can click to inspect what work
+                      // produced this shipped contract.
+                      <div key={doc.id}>
+                        <DocumentRow
+                          doc={doc}
+                          directive={directive}
+                          selected={doc.id === selectedDocumentId}
+                          onSelect={() => onSelectDocument(directive.id, doc)}
+                          indent="deep"
+                        />
+                        <DocumentTasksFolder
+                          documentId={doc.id}
+                          depth="deep"
+                          defaultOpen={false}
+                          refreshNonce={refreshNonce}
+                        />
+                      </div>
+                    ))}
+                </div>
               )}
-            </li>
+            </>
           )}
-        </ul>
+        </div>
       )}
     </div>
   );
 }
 
-/**
- * Read-only viewer for a frozen contract revision. We render the markdown as
- * plain pre-formatted text — these are immutable historical records, not
- * places to edit. A header strip shows the PR state and a deep link.
- */
-function RevisionViewer({
-  directiveId,
-  revisionId,
-}: {
-  directiveId: string;
-  revisionId: string;
-}) {
-  const [revision, setRevision] = useState<DirectiveRevision | null>(null);
-  const [loading, setLoading] = useState(true);
+// =============================================================================
+// DocumentRow — one row inside a directive folder. The indent depth differs
+// between active rows (one level deep) and shipped rows (two levels deep).
+// =============================================================================
+
+interface DocumentRowProps {
+  doc: DirectiveDocument;
+  directive: DirectiveSummary;
+  selected: boolean;
+  onSelect: () => void;
+  indent?: "normal" | "deep";
+}
+
+function DocumentRow({
+  doc,
+  directive,
+  selected,
+  onSelect,
+  indent = "normal",
+}: DocumentRowProps) {
+  const dot = DOC_STATUS_DOT[doc.status] ?? DOC_STATUS_DOT.draft;
+  const padLeft = indent === "deep" ? "pl-[88px]" : "pl-14";
+  const name = `${fileLabel(doc, directive)}.md`;
+
+  return (
+    <button
+      type="button"
+      onClick={onSelect}
+      title={name}
+      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] transition-colors ${
+        selected
+          ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
+          : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
+      }`}
+    >
+      <FileIcon />
+      <span
+        className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`}
+        aria-hidden
+        title={doc.status}
+      />
+      <span className="truncate flex-1">{name}</span>
+      {/* Status chip — only shown for non-active states so the row stays
+          uncluttered for the common case. */}
+      {doc.status !== "active" && (
+        <span className="text-[9px] uppercase tracking-wide text-[#556677]">
+          {doc.status}
+        </span>
+      )}
+      {/* PR badge for shipped docs. The link short-circuits the row's
+          onClick so clicking the PR doesn't also re-select the doc. */}
+      {doc.prUrl && (doc.status === "shipped" || doc.status === "archived") && (
+        <a
+          href={doc.prUrl}
+          target="_blank"
+          rel="noreferrer noopener"
+          onClick={(e) => e.stopPropagation()}
+          className="text-[9px] text-[#75aafc] hover:text-white border border-[#2a3a5a] rounded px-1"
+          title={doc.prUrl}
+        >
+          PR
+        </a>
+      )}
+    </button>
+  );
+}
+
+// =============================================================================
+// Per-document tasks/ subfolder — fetches the steps + ephemeral tasks for a
+// single document and renders a collapsible `tasks/` row beneath the
+// document. Lazy: fetch only fires once the user opens the folder, and we
+// also keep the folder closed by default for shipped docs (where it's
+// historical) and open by default for active docs (where it's live work).
+// =============================================================================
+
+interface DocumentTasksFolderProps {
+  documentId: string;
+  /** Visual indent depth — mirrors the parent DocumentRow's indent so the
+   *  tasks/ row sits one level deeper than its parent doc. */
+  depth: "normal" | "deep";
+  /** Whether to fetch+open by default. Active docs default to open so the
+   *  user sees their live tasks immediately; shipped docs default to closed
+   *  (historical), and the user can click to expand. */
+  defaultOpen: boolean;
+  /** Bumped externally so the folder refetches its task list after a save
+   *  or status change elsewhere. Same nonce used for the directive folder. */
+  refreshNonce: number;
+}
+
+function DocumentTasksFolder({
+  documentId,
+  depth,
+  defaultOpen,
+  refreshNonce,
+}: DocumentTasksFolderProps) {
+  const [open, setOpen] = useState(defaultOpen);
+  const [data, setData] = useState<DocumentTasksResponse | null>(null);
+  const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);
 
-  useEffect(() => {
-    let cancelled = false;
+  // Inner row indent is one level deeper than the folder header. Folder
+  // header uses pl-[88px] (deep) or pl-14 (normal); tasks rows go one
+  // step beyond that.
+  const headerPadLeft = depth === "deep" ? "pl-[88px]" : "pl-14";
+  const rowPadLeft = depth === "deep" ? "pl-[112px]" : "pl-[72px]";
+
+  const refresh = useCallback(async () => {
     setLoading(true);
     setError(null);
-    listDirectiveRevisions(directiveId)
-      .then((res) => {
-        if (cancelled) return;
-        const found = res.revisions.find((r) => r.id === revisionId) ?? null;
-        if (!found) setError("Revision not found");
-        setRevision(found);
-      })
-      .catch((err) => {
-        if (cancelled) return;
-        setError(err instanceof Error ? err.message : String(err));
-      })
-      .finally(() => {
-        if (!cancelled) setLoading(false);
-      });
-    return () => {
-      cancelled = true;
-    };
-  }, [directiveId, revisionId]);
+    try {
+      const res = await listDirectiveDocumentTasks(documentId);
+      setData(res);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : "Failed to load tasks");
+    } finally {
+      setLoading(false);
+    }
+  }, [documentId]);
 
-  if (loading) {
-    return (
-      <div className="flex-1 flex items-center justify-center">
-        <p className="text-[#556677] font-mono text-[12px]">Loading revision…</p>
-      </div>
-    );
-  }
-  if (error || !revision) {
-    return (
-      <div className="flex-1 flex items-center justify-center">
-        <p className="text-red-400 font-mono text-[12px]">
-          {error ?? "Revision not found"}
-        </p>
-      </div>
-    );
+  // Fetch when the folder is open (initial open or refresh). We don't
+  // pre-fetch on closed folders so we don't waste bandwidth on the long
+  // tail of historical shipped docs the user never expands.
+  useEffect(() => {
+    if (!open) return;
+    void refresh();
+  }, [open, refresh, refreshNonce]);
+
+  const total = (data?.steps.length ?? 0) + (data?.tasks.length ?? 0);
+
+  // Don't render the folder at all if we've fetched and the document has
+  // no tasks. This is the cleanest visual: a draft document just shows up
+  // as a single row with no children. The empty-folder check is gated on
+  // a successful fetch so we don't flash "no tasks/" rows during loading.
+  if (data && total === 0 && !loading && !error) {
+    return null;
   }
 
   return (
-    <div className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628]">
-      <div className="flex-1 overflow-y-auto">
-        <div className="max-w-3xl mx-auto px-8 py-10 text-[#dbe7ff]">
-          <div className="flex items-center gap-3 mb-1">
-            <h1 className="text-[24px] font-medium text-white tracking-tight">
-              v{revision.version}
-            </h1>
-            <RevisionStateBadge prState={revision.prState} />
-          </div>
-          <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide mb-1">
-            Frozen {new Date(revision.frozenAt).toLocaleString()}
-          </p>
-          <p className="text-[11px] font-mono text-[#7788aa] mb-8">
-            <a
-              href={revision.prUrl}
-              target="_blank"
-              rel="noreferrer"
-              className="text-[#75aafc] hover:text-[#9bc3ff] underline"
-            >
-              {revision.prUrl}
-            </a>
-          </p>
-
-          {/* Render the frozen markdown as plain pre-formatted text. We
-              deliberately do not parse it into rich nodes — the goal is to
-              show the historical record exactly as it was at PR time. */}
-          <pre className="whitespace-pre-wrap break-words font-mono text-[13px] leading-relaxed text-[#e0eaf8]">
-            {revision.content}
-          </pre>
+    <div>
+      <button
+        type="button"
+        onClick={() => setOpen((v) => !v)}
+        className={`w-full flex items-center gap-1.5 ${headerPadLeft} pr-3 py-1 font-mono text-[11px] text-[#7788aa] hover:bg-[rgba(117,170,252,0.06)]`}
+      >
+        <Caret open={open} />
+        <FolderIcon open={open} />
+        <span>tasks/</span>
+        {total > 0 && (
+          <span className="ml-auto text-[10px] text-[#556677]">{total}</span>
+        )}
+      </button>
+      {open && (
+        <div className="py-0.5">
+          {loading && !data && (
+            <div className={`${rowPadLeft} pr-3 py-1 font-mono text-[10px] text-[#556677]`}>
+              Loading tasks…
+            </div>
+          )}
+          {error && (
+            <div className={`${rowPadLeft} pr-3 py-1 font-mono text-[10px] text-red-400`}>
+              {error}
+            </div>
+          )}
+          {data?.steps.map((step) => (
+            <StepRow key={`step-${step.id}`} step={step} padLeft={rowPadLeft} />
+          ))}
+          {data?.tasks.map((task) => (
+            <TaskRow key={`task-${task.id}`} task={task} padLeft={rowPadLeft} />
+          ))}
         </div>
-      </div>
+      )}
     </div>
   );
 }
 
-/** Tiny pill showing the PR state of a revision (open / merged / closed). */
-function RevisionStateBadge({ prState }: { prState: string }) {
-  const tone =
-    prState === "merged"
-      ? "text-emerald-300 border-emerald-700/60"
-      : prState === "closed"
-      ? "text-[#7788aa] border-[#2a3a5a]"
-      : "text-amber-300 border-amber-600/40";
-  return (
-    <span
-      className={`text-[9px] font-mono uppercase border rounded px-1 py-0 ${tone}`}
-    >
-      {prState}
-    </span>
-  );
+// Step status → coloured dot, mirroring directive status palette so the
+// sidebar reads consistently.
+const STEP_STATUS_DOT: Record<string, string> = {
+  pending: "bg-[#556677]",
+  ready: "bg-[#9bc3ff]",
+  running: "bg-yellow-400",
+  completed: "bg-green-400",
+  failed: "bg-red-400",
+  skipped: "bg-[#3a4a6a]",
+};
+
+// Task status → coloured dot. Statuses come from the Task model; the small
+// set we expect in directive context is enough — anything else falls back
+// to the muted "draft" colour.
+const TASK_STATUS_DOT: Record<string, string> = {
+  pending: "bg-[#556677]",
+  starting: "bg-yellow-400",
+  running: "bg-yellow-400",
+  completed: "bg-green-400",
+  failed: "bg-red-400",
+  cancelled: "bg-[#3a4a6a]",
+  interrupted: "bg-orange-400",
+};
+
+interface StepRowProps {
+  step: DirectiveStep;
+  padLeft: string;
 }
 
-/**
- * Right-side status indicator. Composes the colored status dot with optional
- * "live" pulse (orchestrator running) and "glow" attention ring (pending user
- * question waiting on a response).
- */
-function StatusDot({
-  color,
-  live,
-  glow,
-  status,
-}: {
-  color: string;
-  live: boolean;
-  glow: boolean;
-  status: string;
-}) {
-  // The glow is a soft amber ring pulsed via box-shadow. Keep it subtle so it
-  // doesn't fight the live pulse for attention when both are present.
-  const ring = glow
-    ? "shadow-[0_0_0_2px_rgba(251,191,36,0.45),0_0_8px_2px_rgba(251,191,36,0.55)] animate-pulse"
-    : "";
-  const livePulse = live && !glow ? "animate-pulse" : "";
-  const title = glow
-    ? `${status} — needs response`
-    : live
-    ? `${status} — running`
-    : `status: ${status}`;
+function StepRow({ step, padLeft }: StepRowProps) {
+  const dot = STEP_STATUS_DOT[step.status] ?? "bg-[#556677]";
   return (
-    <span
-      className={`inline-block w-2 h-2 rounded-full shrink-0 ${color} ${ring} ${livePulse}`}
-      aria-label={title}
-      title={title}
-    />
+    <div
+      title={`${step.name} (${step.status})`}
+      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] text-[#9bc3ff]`}
+    >
+      <FileIcon />
+      <span
+        className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`}
+        aria-hidden
+        title={step.status}
+      />
+      <span className="truncate flex-1">{step.name}</span>
+      <span className="text-[9px] uppercase tracking-wide text-[#556677]">
+        step
+      </span>
+    </div>
   );
 }
 
-interface FolderTaskRow {
-  taskId: string;
-  /** Directive step id for step kinds — needed for complete/fail/skip APIs. */
-  stepId: string | null;
-  label: string;
-  status: string;
-  kind: "orchestrator-active" | "completion" | "step" | "ephemeral";
+interface TaskRowProps {
+  task: Task;
+  padLeft: string;
 }
 
-function collectTasks(
-  detailed: DirectiveWithSteps | null,
-  summary: DirectiveSummary,
-  ephemeralTasks: TaskSummary[],
-): FolderTaskRow[] {
-  const rows: FolderTaskRow[] = [];
-
-  // Orchestrator (planner) — surfaces only while it's actively running so
-  // the folder is not flooded with stale orchestrator entries.
-  const orchestratorId =
-    detailed?.orchestratorTaskId ?? summary.orchestratorTaskId ?? null;
-  if (orchestratorId) {
-    rows.push({
-      taskId: orchestratorId,
-      stepId: null,
-      label: "orchestrator",
-      status: "running",
-      kind: "orchestrator-active",
-    });
-  }
-
-  // Completion (PR creation) task.
-  const completionId =
-    detailed?.completionTaskId ?? summary.completionTaskId ?? null;
-  if (completionId) {
-    rows.push({
-      taskId: completionId,
-      stepId: null,
-      label: "completion",
-      status: "running",
-      kind: "completion",
-    });
-  }
-
-  // Step tasks — only steps that have actually been started have a taskId.
-  if (detailed) {
-    for (const step of detailed.steps) {
-      if (!step.taskId) continue;
-      rows.push({
-        taskId: step.taskId,
-        stepId: step.id,
-        label: step.name,
-        status: step.status,
-        kind: "step",
-      });
-    }
-  }
-
-  // Ephemeral tasks — user-spawned spinoffs not part of the DAG. Surfaced
-  // alongside step tasks but with a different icon and the "ephemeral" kind
-  // so context menus and the merge button behave correctly.
-  for (const t of ephemeralTasks) {
-    rows.push({
-      taskId: t.id,
-      stepId: null,
-      label: t.name,
-      status: t.status,
-      kind: "ephemeral",
-    });
-  }
-
-  return rows;
+function TaskRow({ task, padLeft }: TaskRowProps) {
+  const dot = TASK_STATUS_DOT[task.status] ?? "bg-[#556677]";
+  // Supervisor tasks get a small "sup" tag so the user can spot
+  // contract orchestrators in the list.
+  const isSup = task.isSupervisor;
+  return (
+    <div
+      title={`${task.name} (${task.status})`}
+      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] text-[#9bc3ff]`}
+    >
+      <FileIcon />
+      <span
+        className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`}
+        aria-hidden
+        title={task.status}
+      />
+      <span className="truncate flex-1">{task.name}</span>
+      <span className="text-[9px] uppercase tracking-wide text-[#556677]">
+        {isSup ? "sup" : "task"}
+      </span>
+    </div>
+  );
 }
 
+// =============================================================================
+// Sidebar
+// =============================================================================
+
 interface SidebarProps {
   directives: DirectiveSummary[];
   loading: boolean;
   selection: SidebarSelection | null;
-  onSelect: (sel: SidebarSelection) => void;
-  onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void;
-  onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void;
-  /** Open the inline "+ New task" form for this directive. */
-  onCreateTask: (d: DirectiveSummary) => void;
-  /** Trigger a quick action (start/pause/PR) on the directive. */
-  onQuickAction: (d: DirectiveSummary, action: "start" | "pause" | "pr") => void;
-  /** Navigate to an orphan (no-directive) task's standalone view. */
-  onSelectOrphan: (taskId: string) => void;
+  onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void;
+  onSelectDirective: (directiveId: string) => void;
+  onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
+  refreshNonce: number;
 }
 
 function DocumentSidebar({
   directives,
   loading,
   selection,
-  onSelect,
-  onDirectiveContextMenu,
-  onTaskContextMenu,
-  onCreateTask,
-  onQuickAction,
-  onSelectOrphan,
+  onSelectDocument,
+  onSelectDirective,
+  onCreateDocument,
+  refreshNonce,
 }: SidebarProps) {
-  // Orphan tasks (no directive) — top-level "tmp/" pseudo-folder. Polled
-  // every 5s so newly-spawned standalone tasks appear without a manual
-  // refresh.
-  const [orphanTasks, setOrphanTasks] = useState<TaskSummary[]>([]);
-  useEffect(() => {
-    let cancelled = false;
-    const load = () => {
-      listOrphanTasks()
-        .then((res) => {
-          if (!cancelled) setOrphanTasks(res.tasks);
-        })
-        .catch(() => {
-          /* swallow — tmp/ is a nice-to-have, never blocking */
-        });
+  const groups: Record<SidebarGroup, DirectiveSummary[]> = useMemo(() => {
+    const out: Record<SidebarGroup, DirectiveSummary[]> = {
+      active: [],
+      idle: [],
+      archived: [],
     };
-    load();
-    const interval = setInterval(load, 5000);
-    return () => {
-      cancelled = true;
-      clearInterval(interval);
-    };
-  }, []);
-  const [tmpOpen, setTmpOpen] = useState<boolean>(true);
-  // Pending user questions — drives the "glow" attention ring. We split into
-  // two indices so the directive folder header glows whenever ANY of its
-  // tasks has a pending question, while individual task rows glow only for
-  // their own question.
-  const { pendingQuestions } = useSupervisorQuestions();
-  const { directivesWithPending, tasksWithPending } = useMemo(() => {
-    const dirs = new Set<string>();
-    const tasks = new Set<string>();
-    for (const q of pendingQuestions) {
-      if (q.directiveId) dirs.add(q.directiveId);
-      if (q.taskId) tasks.add(q.taskId);
+    for (const d of directives) {
+      out[bucketOf(d.status)].push(d);
     }
-    return { directivesWithPending: dirs, tasksWithPending: tasks };
-  }, [pendingQuestions]);
-
-  // Sort active first, then idle, then paused, then drafts, then inactive
-  // (shipped contracts are quieter), then archived.
-  const sorted = useMemo(() => {
-    const order: Record<DirectiveStatus, number> = {
-      active: 0,
-      paused: 1,
-      idle: 2,
-      draft: 3,
-      inactive: 4,
-      archived: 5,
-    };
-    return [...directives].sort((a, b) => {
-      const oa = order[a.status] ?? 99;
-      const ob = order[b.status] ?? 99;
-      if (oa !== ob) return oa - ob;
-      return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
+    // Sort each group alphabetically so it feels like a stable file tree.
+    (Object.keys(out) as SidebarGroup[]).forEach((k) => {
+      out[k].sort((a, b) =>
+        a.title.localeCompare(b.title, undefined, { sensitivity: "base" }),
+      );
     });
+    return out;
   }, [directives]);
 
-  // Track which directive folders are open. The currently selected directive
-  // is forced open so deep links land on something visible.
-  const [openIds, setOpenIds] = useState<Set<string>>(new Set());
-  const lastSelectedRef = useRef<string | null>(null);
+  // Default-collapsed state per group folder.
+  const [openGroups, setOpenGroups] = useState<Record<SidebarGroup, boolean>>({
+    active: true,
+    idle: true,
+    archived: false,
+  });
+
+  // Per-directive open state. We auto-open the directive containing the
+  // current selection so the user can see what they're editing.
+  const [openDirectives, setOpenDirectives] = useState<Record<string, boolean>>({});
+
   useEffect(() => {
-    if (selection && selection.directiveId !== lastSelectedRef.current) {
-      lastSelectedRef.current = selection.directiveId;
-      setOpenIds((prev) => {
-        if (prev.has(selection.directiveId)) return prev;
-        const next = new Set(prev);
-        next.add(selection.directiveId);
-        return next;
-      });
-    }
-  }, [selection]);
-
-  const toggleOpen = useCallback((id: string) => {
-    setOpenIds((prev) => {
-      const next = new Set(prev);
-      if (next.has(id)) next.delete(id);
-      else next.add(id);
-      return next;
-    });
-  }, []);
+    if (!selection) return;
+    setOpenDirectives((prev) =>
+      prev[selection.directiveId] ? prev : { ...prev, [selection.directiveId]: true },
+    );
+  }, [selection?.directiveId]);
+
+  const toggleGroup = (g: SidebarGroup) =>
+    setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] }));
+
+  const toggleDirective = (id: string) =>
+    setOpenDirectives((prev) => ({ ...prev, [id]: !prev[id] }));
 
   return (
     <div className="flex flex-col h-full">
       {/* Sidebar header */}
       <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]">
         <span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide">
-          Contracts
+          Documents
         </span>
         <span className="text-[10px] font-mono text-[#556677]">
           {directives.length}
         </span>
       </div>
 
-      {/* Top-level "contracts/" folder header (informational, non-interactive). */}
+      {/* Top-level "directives/" folder */}
       <div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono text-[#9bc3ff]">
         <FolderIcon open />
-        <span>contracts/</span>
+        <span>directives/</span>
       </div>
 
       {/* Body */}
       <div className="flex-1 overflow-y-auto pb-4">
-        {/* tmp/ pseudo-folder — orphan tasks (directive_id NULL). Always
-            rendered so users can create scratchpad tasks even when zero
-            directives exist; collapses to a thin header when empty. */}
-        <div className="select-none border-b border-dashed border-[rgba(117,170,252,0.1)] pb-1 mb-1">
-          <button
-            type="button"
-            onClick={() => setTmpOpen((p) => !p)}
-            className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
-          >
-            <Caret open={tmpOpen} />
-            <FolderIcon open={tmpOpen} />
-            <span className="truncate flex-1 text-left text-[#7788aa]">tmp/</span>
-            <span className="text-[10px] text-[#556677]">
-              {orphanTasks.length}
-            </span>
-          </button>
-          {tmpOpen && (
-            <ul className="py-0.5">
-              {orphanTasks.length === 0 ? (
-                <li className="pl-8 pr-3 py-1 font-mono text-[10px] text-[#556677] italic">
-                  No orphan tasks
-                </li>
-              ) : (
-                orphanTasks.map((t) => {
-                  const tdot =
-                    STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending;
-                  const live = t.status === "running";
-                  return (
-                    <li key={t.id}>
-                      <button
-                        type="button"
-                        onClick={() => onSelectOrphan(t.id)}
-                        title={t.name}
-                        className="w-full text-left flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent transition-colors"
-                      >
-                        <TaskIcon />
-                        <span className="truncate flex-1">{t.name}</span>
-                        <StatusDot
-                          color={tdot}
-                          live={live}
-                          glow={false}
-                          status={t.status}
-                        />
-                      </button>
-                    </li>
-                  );
-                })
-              )}
-            </ul>
-          )}
-        </div>
-
         {loading && directives.length === 0 ? (
           <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
             Loading...
           </div>
         ) : directives.length === 0 ? (
           <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
-            No contracts yet
+            No directives yet
           </div>
         ) : (
-          sorted.map((d) => (
-            <DirectiveFolder
-              key={d.id}
-              directive={d}
-              open={openIds.has(d.id)}
-              onToggle={() => toggleOpen(d.id)}
-              selection={selection}
-              onSelect={onSelect}
-              pendingTaskIds={tasksWithPending}
-              hasPendingForDirective={directivesWithPending.has(d.id)}
-              onDirectiveContextMenu={onDirectiveContextMenu}
-              onTaskContextMenu={onTaskContextMenu}
-              onCreateTask={onCreateTask}
-              onQuickAction={onQuickAction}
-            />
-          ))
+          (Object.keys(groups) as SidebarGroup[]).map((group) => {
+            const list = groups[group];
+            if (list.length === 0) return null;
+            const open = openGroups[group];
+            return (
+              <div key={group} className="select-none">
+                {/* Group header (sub-folder) */}
+                <button
+                  type="button"
+                  onClick={() => toggleGroup(group)}
+                  className="w-full flex items-center gap-1.5 pl-4 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
+                >
+                  <Caret open={open} />
+                  <FolderIcon open={open} />
+                  <span>{GROUP_LABEL[group]}/</span>
+                  <span className="ml-auto text-[10px] text-[#556677]">
+                    {list.length}
+                  </span>
+                </button>
+
+                {/* Each directive is a folder containing N documents. */}
+                {open && (
+                  <div className="py-0.5">
+                    {list.map((d) => (
+                      <DirectiveFolder
+                        key={d.id}
+                        directive={d}
+                        open={!!openDirectives[d.id]}
+                        onToggle={() => toggleDirective(d.id)}
+                        onHeaderClick={() => onSelectDirective(d.id)}
+                        selection={selection}
+                        onSelectDocument={onSelectDocument}
+                        onCreateDocument={onCreateDocument}
+                        refreshNonce={refreshNonce}
+                      />
+                    ))}
+                  </div>
+                )}
+              </div>
+            );
+          })
         )}
       </div>
     </div>
@@ -1166,108 +800,112 @@ function DocumentSidebar({
 
 // =============================================================================
 // Editor shell — wraps DocumentEditor and handles the "no document selected"
-// and loading states.
+// and loading states. Two modes:
+//   1) documentId selected → fetch the DirectiveDocument and edit doc.body via
+//      updateDirectiveDocument (the call that auto-reactivates a shipped doc).
+//   2) no documentId (legacy fallback, kept for the "select a directive but
+//      not a document" transitional case) → edit directive.goal as before.
 // =============================================================================
 
-/**
- * Wraps DocumentTaskStream with ephemeral-aware metadata. Determines whether
- * the selected task is part of the directive's DAG (orchestrator/completion/
- * steps) or an ephemeral spinoff, and looks up its current status from the
- * ephemeral list — that decides whether the "Merge to base" affordance
- * should appear in the stream's action header.
- */
-function EphemeralAwareTaskStream({
-  taskId,
-  label,
-  directive,
-}: {
-  taskId: string;
-  label: string;
-  directive: DirectiveWithSteps;
-}) {
-  const isStepBound =
-    taskId === directive.orchestratorTaskId ||
-    taskId === directive.completionTaskId ||
-    directive.steps.some((s) => s.taskId === taskId);
-
-  // Status lookup for ephemeral tasks. We poll the ephemeral list lazily —
-  // this is a lightweight call and only triggers when the user is viewing a
-  // task in the editor pane.
-  const [ephemeralStatus, setEphemeralStatus] = useState<string | undefined>();
-  useEffect(() => {
-    if (isStepBound) return;
-    let cancelled = false;
-    const load = () => {
-      listDirectiveEphemeralTasks(directive.id)
-        .then((res) => {
-          if (cancelled) return;
-          const match = res.tasks.find((t) => t.id === taskId);
-          setEphemeralStatus(match?.status);
-        })
-        .catch(() => {
-          /* non-blocking */
-        });
-    };
-    load();
-    const interval = setInterval(load, 5000);
-    return () => {
-      cancelled = true;
-      clearInterval(interval);
-    };
-  }, [taskId, directive.id, isStepBound]);
-
-  return (
-    <DocumentTaskStream
-      taskId={taskId}
-      label={label}
-      ephemeral={!isStepBound}
-      status={ephemeralStatus}
-    />
-  );
-}
-
 interface EditorShellProps {
-  selectedId: string | undefined;
-  selectedTaskId: string | null;
+  selection: SidebarSelection | null;
   hasDirectives: boolean;
   listLoading: boolean;
-  onClearTask: () => void;
+  /** Bumped after a successful document save so the sidebar refetches. */
+  onDocumentChanged: () => void;
 }
 
 function EditorShell({
-  selectedId,
-  selectedTaskId,
+  selection,
   hasDirectives,
   listLoading,
-  onClearTask,
+  onDocumentChanged,
 }: EditorShellProps) {
+  const directiveId = selection?.directiveId;
+  const documentId = selection?.documentId ?? null;
+
+  // We deliberately don't pull `updateGoal` here — in the multi-document
+  // world, edits flow through updateDirectiveDocument (which auto-reactivates
+  // a shipped doc when its body changes). The legacy directive.goal is
+  // unused on this surface.
   const {
     directive,
-    loading,
-    updateGoal,
+    loading: directiveLoading,
     cleanup,
     createPR,
     pickUpOrders,
-  } = useDirective(selectedId);
+  } = useDirective(directiveId);
+
+  // Document fetch — only when documentId is selected. Refetched whenever the
+  // id changes; not polled (the document stream is too low-traffic to warrant
+  // background refresh in this iteration).
+  const [doc, setDoc] = useState<DirectiveDocument | null>(null);
+  const [docLoading, setDocLoading] = useState(false);
+  const [docError, setDocError] = useState<string | null>(null);
+
+  useEffect(() => {
+    if (!documentId) {
+      setDoc(null);
+      setDocLoading(false);
+      setDocError(null);
+      return;
+    }
+    let cancelled = false;
+    setDocLoading(true);
+    setDocError(null);
+    getDirectiveDocument(documentId)
+      .then((d) => {
+        if (cancelled) return;
+        setDoc(d);
+      })
+      .catch((e) => {
+        if (cancelled) return;
+        setDocError(e instanceof Error ? e.message : "Failed to load document");
+      })
+      .finally(() => {
+        if (cancelled) return;
+        setDocLoading(false);
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, [documentId]);
+
+  // Save callback for the document path. The backend re-stamps a shipped doc
+  // back to active when its body changes, so we just optimistically update
+  // local state with the server's response.
+  const onUpdateDocumentBody = useCallback(
+    async (body: string) => {
+      if (!documentId) return;
+      const updated = await updateDirectiveDocument(documentId, { body });
+      setDoc(updated);
+      // Tell the sidebar to refetch the directive's document list so the
+      // status chip flips from `shipped` back to `active` (and any title
+      // changes propagate). Cheap — folders only refetch when open.
+      onDocumentChanged();
+    },
+    [documentId, onDocumentChanged],
+  );
 
-  if (!selectedId) {
+  // ---- Empty / error / loading states ------------------------------------
+  if (!directiveId) {
     return (
       <div className="flex-1 flex items-center justify-center h-full">
         <p className="text-[#556677] font-mono text-[12px]">
           {listLoading
-            ? "Loading contracts..."
+            ? "Loading documents..."
             : hasDirectives
-            ? "Select a contract from the sidebar"
-            : "No contracts yet — create one from the legacy UI"}
+            ? "Select a document from the sidebar"
+            : "No directives yet — create one from the legacy UI"}
         </p>
       </div>
     );
   }
 
-  if (loading && !directive) {
+  if (directiveLoading && !directive) {
     return (
       <div className="flex-1 flex items-center justify-center h-full">
-        <p className="text-[#556677] font-mono text-[12px]">Loading contract...</p>
+        <p className="text-[#556677] font-mono text-[12px]">Loading directive...</p>
       </div>
     );
   }
@@ -1275,108 +913,86 @@ function EditorShell({
   if (!directive) {
     return (
       <div className="flex-1 flex items-center justify-center h-full">
-        <p className="text-[#7788aa] font-mono text-[12px]">Contract not found</p>
+        <p className="text-[#7788aa] font-mono text-[12px]">Directive not found</p>
       </div>
     );
   }
 
-  // The "task" param can encode either a real task id, or a revision via the
-  // `revision:<uuid>` prefix. Split that out so the right pane can switch
-  // between the live task stream and the read-only revision viewer.
-  const revisionId =
-    selectedTaskId && selectedTaskId.startsWith("revision:")
-      ? selectedTaskId.slice("revision:".length)
-      : null;
-  const realTaskId = revisionId ? null : selectedTaskId;
-
-  // Resolve the label for the breadcrumb when a task is selected.
-  const taskLabel = realTaskId
-    ? realTaskId === directive.orchestratorTaskId
-      ? "orchestrator"
-      : realTaskId === directive.completionTaskId
-      ? "completion"
-      : directive.steps.find((s) => s.taskId === realTaskId)?.name ??
-        realTaskId.slice(0, 8)
-    : revisionId
-    ? "revision"
-    : null;
-
-  // "Now executing" strip — surfaces what's live when looking at the
-  // contract editor, so users don't have to scan the sidebar to find it.
-  const liveTask = (() => {
-    if (selectedTaskId) return null; // already viewing a task; strip is redundant
-    if (directive.orchestratorTaskId) {
-      return { id: directive.orchestratorTaskId, name: "orchestrator" };
+  // --- Document path: documentId selected --------------------------------
+  if (documentId) {
+    if (docLoading && !doc) {
+      return (
+        <div className="flex-1 flex items-center justify-center h-full">
+          <p className="text-[#556677] font-mono text-[12px]">Loading document...</p>
+        </div>
+      );
     }
-    if (directive.completionTaskId) {
-      return { id: directive.completionTaskId, name: "completion" };
+    if (docError) {
+      return (
+        <div className="flex-1 flex items-center justify-center h-full">
+          <p className="text-red-400 font-mono text-[12px]">{docError}</p>
+        </div>
+      );
     }
-    const runningStep = directive.steps.find((s) => s.status === "running");
-    if (runningStep && runningStep.taskId) {
-      return { id: runningStep.taskId, name: runningStep.name };
+    if (!doc) {
+      return (
+        <div className="flex-1 flex items-center justify-center h-full">
+          <p className="text-[#7788aa] font-mono text-[12px]">Document not found</p>
+        </div>
+      );
     }
-    return null;
-  })();
 
-  return (
-    <div className="flex-1 flex flex-col h-full overflow-hidden">
-      {/* Contract header — breadcrumb-like, mirrors a code editor's tab bar */}
-      <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
-        <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
-          <FileIcon />
-          <span>contracts /</span>
-          <span className="text-[#9bc3ff]">{directive.id.slice(0, 8)}</span>
-          {selectedTaskId && (
-            <>
-              <span>/</span>
-              <span className="text-[#9bc3ff]">{taskLabel}</span>
-              <button
-                type="button"
-                onClick={onClearTask}
-                className="ml-2 px-1.5 py-0.5 text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded normal-case"
-              >
-                back to contract
-              </button>
-            </>
-          )}
-        </div>
-      </div>
+    // Synthesise a directive-shaped object whose `goal` is the document body.
+    // DocumentEditor was originally written against DirectiveWithSteps, so we
+    // can keep its shape by overriding `goal` with `doc.body` and `title`
+    // with the document's filename label. The steps panel still draws from
+    // the real directive (passed through StepsBlockContextProvider).
+    const docTitle = `${fileLabel(doc, directive)}.md`;
+    const directiveAsDocument = {
+      ...directive,
+      goal: doc.body,
+      title: docTitle,
+    };
 
-      {/* Now-executing strip — only when viewing the contract doc itself.
-          Click to jump into the live task transcript. */}
-      {liveTask && (
-        <button
-          type="button"
-          onClick={() =>
-            // Navigate via the search-param so EditorShell switches to the
-            // task stream for this live task.
-            (window.location.search = `?task=${liveTask.id}`)
-          }
-          className="shrink-0 flex items-center gap-2 px-6 py-1.5 bg-amber-900/15 border-b border-amber-700/40 text-amber-300 font-mono text-[11px] hover:bg-amber-900/30 transition-colors"
-        >
-          <span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
-          <span className="uppercase tracking-wide text-[10px]">Now executing</span>
-          <span className="text-[#dbe7ff]">{liveTask.name}</span>
-          <span className="ml-auto text-[10px] text-amber-200/70">
-            click to view transcript ↗
-          </span>
-        </button>
-      )}
+    return (
+      <div className="flex-1 flex flex-col h-full overflow-hidden">
+        {/* Breadcrumb — directives / <directive title> / <document title>.md */}
+        <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
+          <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
+            <FileIcon />
+            <span>directives /</span>
+            <span className="text-[#9bc3ff]">
+              {directive.title.trim().length > 0
+                ? directive.title
+                : directive.id.slice(0, 8)}
+            </span>
+            <span>/</span>
+            <span className="text-white">{docTitle}</span>
+            {doc.status === "shipped" && (
+              <span className="ml-2 text-[#75aafc] normal-case">shipped</span>
+            )}
+            {doc.status === "archived" && (
+              <span className="ml-2 text-[#7788aa] normal-case">archived</span>
+            )}
+            {doc.status === "draft" && (
+              <span className="ml-2 text-[#556677] normal-case">draft</span>
+            )}
+            {!!directive.orchestratorTaskId && (
+              <span className="ml-auto inline-flex items-center gap-1 text-yellow-400">
+                <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
+                orchestrator running
+              </span>
+            )}
+          </div>
+        </div>
 
-      {revisionId ? (
-        <RevisionViewer directiveId={directive.id} revisionId={revisionId} />
-      ) : realTaskId ? (
-        <EphemeralAwareTaskStream
-          taskId={realTaskId}
-          label={taskLabel ?? realTaskId.slice(0, 8)}
-          directive={directive}
-        />
-      ) : (
         <DocumentEditor
-          directive={directive}
-          onUpdateGoal={async (goal) => {
-            await updateGoal(goal);
-          }}
+          // Keying by document id ensures the Lexical editor remounts cleanly
+          // when the user switches documents, so the previous doc's body
+          // doesn't bleed into the new one.
+          key={doc.id}
+          directive={directiveAsDocument}
+          onUpdateGoal={onUpdateDocumentBody}
           onCleanup={async () => {
             await cleanup();
           }}
@@ -1387,7 +1003,33 @@ function EditorShell({
             await pickUpOrders();
           }}
         />
-      )}
+      </div>
+    );
+  }
+
+  // --- Legacy fallback: directive selected but no document chosen --------
+  // We only ever land here transiently while the page resolves the default
+  // document selection, so we render a thin "loading" placeholder rather
+  // than the full goal editor (which would be confusing alongside the new
+  // multi-document model).
+  return (
+    <div className="flex-1 flex flex-col h-full overflow-hidden">
+      <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
+        <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
+          <FileIcon />
+          <span>directives /</span>
+          <span className="text-[#9bc3ff]">
+            {directive.title.trim().length > 0
+              ? directive.title
+              : directive.id.slice(0, 8)}
+          </span>
+        </div>
+      </div>
+      <div className="flex-1 flex items-center justify-center">
+        <p className="text-[#556677] font-mono text-[12px]">
+          Select a document, or click "+ New document" to create one.
+        </p>
+      </div>
     </div>
   );
 }
@@ -1396,25 +1038,31 @@ function EditorShell({
 // Page
 // =============================================================================
 
-type ContextMenuState =
-  | { kind: "directive"; x: number; y: number; directive: DirectiveSummary }
-  | {
-      kind: "task";
-      x: number;
-      y: number;
-      task: FolderTaskRow;
-      directiveId: string;
-    }
-  | null;
-
 export default function DocumentDirectivesPage() {
   const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
   const navigate = useNavigate();
-  const { id: selectedId } = useParams<{ id: string }>();
+  const { id: routeDirectiveId } = useParams<{ id: string }>();
   const [searchParams, setSearchParams] = useSearchParams();
-  const selectedTaskId = searchParams.get("task");
-  const { directives, loading: listLoading, refresh: refreshList } = useDirectives();
-  const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
+  const { directives, loading: listLoading } = useDirectives();
+
+  // refreshNonce — bumped to tell open directive folders to refetch their
+  // document lists (after a create or save).
+  const [refreshNonce, setRefreshNonce] = useState(0);
+  const bumpRefresh = useCallback(() => setRefreshNonce((n) => n + 1), []);
+
+  // Derive the SidebarSelection from the URL. The route param is the
+  // directive id; ?document=:id and ?task=:id pick a specific child. Exactly
+  // one of taskId/documentId can be set; if both happen to be present in the
+  // URL (which shouldn't happen via our nav code) we prefer ?task= since
+  // task selection is the more disruptive action.
+  const selection: SidebarSelection | null = useMemo(() => {
+    if (!routeDirectiveId) return null;
+    const taskId = searchParams.get("task");
+    const documentId = searchParams.get("document");
+    if (taskId) return { directiveId: routeDirectiveId, taskId, documentId: null };
+    if (documentId) return { directiveId: routeDirectiveId, taskId: null, documentId };
+    return { directiveId: routeDirectiveId, taskId: null, documentId: null };
+  }, [routeDirectiveId, searchParams]);
 
   useEffect(() => {
     if (!authLoading && isAuthConfigured && !isAuthenticated) {
@@ -1422,96 +1070,86 @@ export default function DocumentDirectivesPage() {
     }
   }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
 
-  const onSelect = useCallback(
-    (sel: SidebarSelection) => {
-      const next = `/directives/${sel.directiveId}${
-        sel.taskId ? `?task=${sel.taskId}` : ""
-      }`;
-      navigate(next);
-    },
-    [navigate],
-  );
+  // ------------------------------------------------------------------
+  // Default-selection: when the user clicks a directive's folder header (or
+  // lands on /directives/:id without ?document=) we pick the first active or
+  // draft document and update the URL to point at it. This avoids the
+  // "directive selected, but nothing in the editor" intermediate state.
+  // ------------------------------------------------------------------
+  const lastResolvedRef = useRef<string | null>(null);
+  useEffect(() => {
+    if (!routeDirectiveId) {
+      lastResolvedRef.current = null;
+      return;
+    }
+    // Only auto-resolve when no document/task has been picked yet, AND we
+    // haven't already resolved this directive in a prior tick (otherwise
+    // navigating away from the doc would instantly re-pick the same one).
+    if (selection?.documentId || selection?.taskId) {
+      lastResolvedRef.current = routeDirectiveId;
+      return;
+    }
+    if (lastResolvedRef.current === routeDirectiveId) return;
+    lastResolvedRef.current = routeDirectiveId;
 
-  const onClearTask = useCallback(() => {
-    const next = new URLSearchParams(searchParams);
-    next.delete("task");
-    setSearchParams(next, { replace: true });
-  }, [searchParams, setSearchParams]);
-
-  const onDirectiveContextMenu = useCallback(
-    (e: React.MouseEvent, d: DirectiveSummary) => {
-      e.preventDefault();
-      e.stopPropagation();
-      setContextMenu({ kind: "directive", x: e.clientX, y: e.clientY, directive: d });
-    },
-    [],
-  );
+    let cancelled = false;
+    listDirectiveDocuments(routeDirectiveId)
+      .then((list) => {
+        if (cancelled) return;
+        // Prefer the first 'active' doc; fall back to the first 'draft'.
+        const firstActive = list.find((d) => d.status === "active");
+        const firstDraft = list.find((d) => d.status === "draft");
+        const pick = firstActive ?? firstDraft;
+        if (pick) {
+          setSearchParams(
+            (prev) => {
+              const next = new URLSearchParams(prev);
+              next.set("document", pick.id);
+              next.delete("task");
+              return next;
+            },
+            { replace: true },
+          );
+        }
+      })
+      .catch(() => {
+        // Swallow — the editor pane will show "Document not found" and the
+        // user can click "+ New document" to recover.
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, [routeDirectiveId, selection?.documentId, selection?.taskId, setSearchParams]);
 
-  const onTaskContextMenu = useCallback(
-    (e: React.MouseEvent, task: FolderTaskRow, directiveId: string) => {
-      e.preventDefault();
-      e.stopPropagation();
-      setContextMenu({ kind: "task", x: e.clientX, y: e.clientY, task, directiveId });
+  const handleSelectDocument = useCallback(
+    (directiveId: string, doc: DirectiveDocument) => {
+      navigate(`/directives/${directiveId}?document=${doc.id}`);
     },
-    [],
-  );
-
-  const closeContextMenu = useCallback(() => setContextMenu(null), []);
-
-  // Inline "+ New task" form state. When set, we render a small modal-ish
-  // overlay anchored to the directive folder; submitting calls the
-  // ephemeral-task endpoint.
-  const [newTaskFor, setNewTaskFor] = useState<DirectiveSummary | null>(null);
-
-  const onCreateTask = useCallback((d: DirectiveSummary) => {
-    setNewTaskFor(d);
-  }, []);
-
-  const handleSubmitNewTask = useCallback(
-    async (name: string, plan: string) => {
-      if (!newTaskFor) return;
-      try {
-        const task = await createDirectiveTask(newTaskFor.id, { name, plan });
-        // Navigate the user into the freshly-spawned task's transcript.
-        navigate(`/directives/${newTaskFor.id}?task=${task.id}`);
-        setNewTaskFor(null);
-      } catch (err) {
-        // eslint-disable-next-line no-console
-        console.error("[makima] failed to create ephemeral task", err);
-        alert(
-          err instanceof Error
-            ? `Failed to create task: ${err.message}`
-            : "Failed to create task",
-        );
-      }
-    },
-    [newTaskFor, navigate],
+    [navigate],
   );
 
-  const onQuickAction = useCallback(
-    async (d: DirectiveSummary, action: "start" | "pause" | "pr") => {
-      try {
-        if (action === "start") {
-          await startDirective(d.id);
-        } else if (action === "pause") {
-          await pauseDirective(d.id);
-        } else if (action === "pr") {
-          await createDirectivePR(d.id);
-        }
-        await refreshList();
-      } catch (err) {
-        // eslint-disable-next-line no-console
-        console.error(`[makima] quick action ${action} failed`, err);
-      }
+  // When the user clicks a directive folder header (not a document row), we
+  // jump to /directives/:id without ?document= — the default-selection
+  // effect above will then pick the first active doc.
+  const handleSelectDirective = useCallback(
+    (directiveId: string) => {
+      if (routeDirectiveId === directiveId) return;
+      navigate(`/directives/${directiveId}`);
     },
-    [refreshList],
+    [navigate, routeDirectiveId],
   );
 
-  const onSelectOrphan = useCallback(
-    (taskId: string) => {
-      navigate(`/tmp/${taskId}`);
+  const handleCreateDocument = useCallback(
+    async (directive: DirectiveSummary) => {
+      const created = await createDirectiveDocument(directive.id, {
+        title: "",
+        body: "",
+      });
+      bumpRefresh();
+      // Navigate to the new doc so it's selected immediately.
+      navigate(`/directives/${directive.id}?document=${created.id}`);
     },
-    [navigate],
+    [bumpRefresh, navigate],
   );
 
   if (authLoading) {
@@ -1525,10 +1163,6 @@ export default function DocumentDirectivesPage() {
     );
   }
 
-  const selection: SidebarSelection | null = selectedId
-    ? { directiveId: selectedId, taskId: selectedTaskId }
-    : null;
-
   return (
     // h-screen + overflow-hidden so the page itself never scrolls; the
     // sidebar and editor pane each manage their own scroll via flex-1
@@ -1546,269 +1180,21 @@ export default function DocumentDirectivesPage() {
             directives={directives}
             loading={listLoading}
             selection={selection}
-            onSelect={onSelect}
-            onDirectiveContextMenu={onDirectiveContextMenu}
-            onTaskContextMenu={onTaskContextMenu}
-            onCreateTask={onCreateTask}
-            onQuickAction={onQuickAction}
-            onSelectOrphan={onSelectOrphan}
+            onSelectDocument={handleSelectDocument}
+            onSelectDirective={handleSelectDirective}
+            onCreateDocument={handleCreateDocument}
+            refreshNonce={refreshNonce}
           />
         </div>
 
-        {/* Right: Lexical editor / task stream */}
+        {/* Right: Lexical editor */}
         <EditorShell
-          selectedId={selectedId}
-          selectedTaskId={selectedTaskId}
+          selection={selection}
           hasDirectives={directives.length > 0}
           listLoading={listLoading}
-          onClearTask={onClearTask}
+          onDocumentChanged={bumpRefresh}
         />
       </main>
-
-      {/* Context menus — rendered at page level so they overlay everything. */}
-      {contextMenu?.kind === "directive" && (
-        <DirectiveContextMenu
-          x={contextMenu.x}
-          y={contextMenu.y}
-          directive={contextMenu.directive}
-          onClose={closeContextMenu}
-          onStart={async () => {
-            await startDirective(contextMenu.directive.id);
-            await refreshList();
-          }}
-          onPause={async () => {
-            await pauseDirective(contextMenu.directive.id);
-            await refreshList();
-          }}
-          onArchive={async () => {
-            await updateDirective(contextMenu.directive.id, {
-              status: "archived",
-            });
-            await refreshList();
-          }}
-          onDelete={async () => {
-            if (
-              !window.confirm(
-                `Delete "${contextMenu.directive.title}"? This cannot be undone.`,
-              )
-            ) {
-              return;
-            }
-            await deleteDirective(contextMenu.directive.id);
-            await refreshList();
-            // If the deleted one was selected, clear selection.
-            if (selectedId === contextMenu.directive.id) {
-              navigate("/directives");
-            }
-          }}
-          onGoToPR={() => {
-            if (contextMenu.directive.prUrl) {
-              window.open(contextMenu.directive.prUrl, "_blank", "noreferrer");
-            }
-          }}
-          onNewDraft={async () => {
-            await newDirectiveDraft(contextMenu.directive.id);
-            await refreshList();
-            // Send the user into the freshly-cleared contract so they can
-            // start typing the next iteration immediately.
-            navigate(`/directives/${contextMenu.directive.id}`);
-          }}
-          onCreatePR={async () => {
-            await createDirectivePR(contextMenu.directive.id);
-            await refreshList();
-          }}
-          onAdvance={async () => {
-            await advanceDirective(contextMenu.directive.id);
-            await refreshList();
-          }}
-          onCleanup={async () => {
-            await cleanupDirective(contextMenu.directive.id);
-            await refreshList();
-          }}
-          onPickUpOrders={async () => {
-            await pickUpOrders(contextMenu.directive.id);
-            await refreshList();
-          }}
-        />
-      )}
-      {contextMenu?.kind === "task" && (
-        <TaskContextMenu
-          x={contextMenu.x}
-          y={contextMenu.y}
-          task={contextMenu.task}
-          onClose={closeContextMenu}
-          onInterrupt={async () => {
-            try {
-              await stopTask(contextMenu.task.taskId);
-            } catch (err) {
-              // eslint-disable-next-line no-console
-              console.error("[makima] failed to interrupt task", err);
-            }
-            await refreshList();
-          }}
-          onComplete={async () => {
-            if (!contextMenu.task.stepId) return;
-            await completeDirectiveStep(
-              contextMenu.directiveId,
-              contextMenu.task.stepId,
-            );
-            await refreshList();
-          }}
-          onFail={async () => {
-            if (!contextMenu.task.stepId) return;
-            await failDirectiveStep(
-              contextMenu.directiveId,
-              contextMenu.task.stepId,
-            );
-            await refreshList();
-          }}
-          onSkip={async () => {
-            if (!contextMenu.task.stepId) return;
-            await skipDirectiveStep(
-              contextMenu.directiveId,
-              contextMenu.task.stepId,
-            );
-            await refreshList();
-          }}
-          onSendMessage={async () => {
-            // Browser prompt is the lightest-weight surface that doesn't
-            // require redesigning a modal. The same comment box is also
-            // available below the live transcript when the task is selected.
-            const message = window.prompt("Send message to task:");
-            if (!message || !message.trim()) return;
-            try {
-              await sendTaskMessage(contextMenu.task.taskId, message.trim());
-            } catch (err) {
-              // eslint-disable-next-line no-console
-              console.error("[makima] failed to send task message", err);
-            }
-          }}
-          onOpenInTaskPage={() => {
-            // The standalone /exec/:taskId page has the full task UI with
-            // worktree diff viewer, checkpoint controls, etc.
-            navigate(`/exec/${contextMenu.task.taskId}`);
-          }}
-        />
-      )}
-
-      {newTaskFor && (
-        <NewTaskModal
-          directive={newTaskFor}
-          onClose={() => setNewTaskFor(null)}
-          onSubmit={handleSubmitNewTask}
-        />
-      )}
-    </div>
-  );
-}
-
-/**
- * Inline "+ New task" form for spawning an ephemeral task under a
- * directive. Surfaced as a centered modal, dismissible with Esc / click-out.
- */
-function NewTaskModal({
-  directive,
-  onClose,
-  onSubmit,
-}: {
-  directive: DirectiveSummary;
-  onClose: () => void;
-  onSubmit: (name: string, plan: string) => Promise<void>;
-}) {
-  const [name, setName] = useState("");
-  const [plan, setPlan] = useState("");
-  const [submitting, setSubmitting] = useState(false);
-  const nameRef = useRef<HTMLInputElement>(null);
-
-  useEffect(() => {
-    nameRef.current?.focus();
-    const onKey = (e: KeyboardEvent) => {
-      if (e.key === "Escape") onClose();
-    };
-    document.addEventListener("keydown", onKey);
-    return () => document.removeEventListener("keydown", onKey);
-  }, [onClose]);
-
-  const submit = async (e: React.FormEvent) => {
-    e.preventDefault();
-    const trimmedName = name.trim();
-    const trimmedPlan = plan.trim();
-    if (!trimmedName || !trimmedPlan || submitting) return;
-    setSubmitting(true);
-    try {
-      await onSubmit(trimmedName, trimmedPlan);
-    } finally {
-      setSubmitting(false);
-    }
-  };
-
-  return (
-    <div
-      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
-      onClick={onClose}
-    >
-      <form
-        onSubmit={submit}
-        onClick={(e) => e.stopPropagation()}
-        className="w-[480px] max-w-[90vw] bg-[#0a1628] border border-[rgba(117,170,252,0.4)] shadow-xl flex flex-col"
-      >
-        <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)]">
-          <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide">
-            New task in
-          </p>
-          <p className="text-[12px] font-mono text-white truncate">
-            {directive.title}
-          </p>
-        </div>
-        <div className="px-4 py-4 space-y-3">
-          <div className="space-y-1">
-            <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
-              Name
-            </label>
-            <input
-              ref={nameRef}
-              type="text"
-              value={name}
-              onChange={(e) => setName(e.target.value)}
-              placeholder="e.g. Investigate flaky test in auth.test.ts"
-              className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566]"
-            />
-          </div>
-          <div className="space-y-1">
-            <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
-              Plan / instructions
-            </label>
-            <textarea
-              value={plan}
-              onChange={(e) => setPlan(e.target.value)}
-              onKeyDown={(e) => {
-                if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
-                  void submit(e as unknown as React.FormEvent);
-                }
-              }}
-              rows={5}
-              placeholder="What should the task do?"
-              className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566] resize-none"
-            />
-          </div>
-        </div>
-        <div className="px-4 py-3 border-t border-dashed border-[rgba(117,170,252,0.25)] flex items-center justify-end gap-2">
-          <button
-            type="button"
-            onClick={onClose}
-            className="px-3 py-1.5 text-[11px] font-mono uppercase tracking-wide text-[#7788aa] border border-[#2a3a5a] hover:text-white"
-          >
-            Cancel
-          </button>
-          <button
-            type="submit"
-            disabled={!name.trim() || !plan.trim() || submitting}
-            className="px-3 py-1.5 text-[11px] font-mono uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed"
-          >
-            {submitting ? "Creating…" : "Spawn task"}
-          </button>
-        </div>
-      </form>
     </div>
   );
 }
-- 
cgit v1.2.3

