diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-flow.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-flow.test.ts
index 65423f822b..6bafe7aba1 100644
--- a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-flow.test.ts
+++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-flow.test.ts
@@ -56,7 +56,13 @@ describe("orchestrator happy path", () => {
it("runs a SECOND stage through the SAME orchestrator with only a registry-data entry (no new route/store code)", async () => {
// Adding a stage = data only.
- registerStage({ stageId: "compound", skillId: "ce-compound", artifactLocation: "docs/solutions/" });
+ registerStage({
+ stageId: "compound",
+ skillId: "ce-compound",
+ artifactLocation: "docs/solutions/",
+ icon: "BookOpen",
+ label: "Compound",
+ });
expect(getStage("compound")?.skillId).toBe("ce-compound");
const orch = makeOrch([{ type: "complete", data: { artifact: "# Learning\n" } }]);
diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-interaction-audit.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-interaction-audit.test.ts
new file mode 100644
index 0000000000..cff9a9c9e8
--- /dev/null
+++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-interaction-audit.test.ts
@@ -0,0 +1,159 @@
+import { describe, expect, it } from "vitest";
+import type { PlanningQuestionType } from "@fusion/core";
+import {
+ RICH_INTERACTION_TYPES,
+ canRenderRichly,
+ isRichInteractionType,
+} from "../dashboard/ce-question-support.js";
+import { getStage } from "../session/stage-registry.js";
+
+/**
+ * Skill-interaction audit (Success Criteria, U6).
+ *
+ * CLASSIFICATION PROVENANCE — be honest. This audit is a DECLARED /
+ * EXPECTED classification, NOT a measurement taken from driving live `ce-*`
+ * skill sessions. We do not invoke a real model here. The interaction types
+ * each stage performs are read from each stage's protocol — the SKILL.md
+ * "Interaction Rules / Interaction Method" sections that govern how the skill
+ * asks questions (e.g. ce-brainstorm: "Ask one question at a time", "Prefer
+ * single-select", "Use multi-select rarely", open-ended free-text questions;
+ * ce-ideate / ce-plan: single-select-preferred + free-text). Each declared
+ * interaction is then classified against CeFlow's renderable set
+ * (`RICH_INTERACTION_TYPES`) to compute a rich-vs-chat coverage ratio.
+ *
+ * The test FAILS if any sampled interaction is unclassified (a type CeFlow's
+ * support module doesn't recognize at all), which is the guard that keeps the
+ * audit honest as the skills' protocols evolve. When a stage declares a
+ * confirm/text/single/multi interaction, that is rich-renderable; an
+ * "unknown_type" declaration would be unclassified and fail.
+ */
+
+interface DeclaredInteraction {
+ /** A label for the interaction occurrence within the stage's protocol. */
+ name: string;
+ /** The interaction type the stage's protocol uses for it. */
+ type: string;
+ /** Whether the stage's protocol supplies options for this interaction. */
+ hasOptions: boolean;
+}
+
+interface StageProtocol {
+ stageId: string;
+ /** Source the declaration was read from (for traceability in the report). */
+ source: string;
+ interactions: DeclaredInteraction[];
+}
+
+/**
+ * Declared protocols for the sampled stages, derived from each SKILL.md's
+ * Interaction section. These are protocol declarations, not live captures.
+ */
+const SAMPLED_STAGES: StageProtocol[] = [
+ {
+ stageId: "brainstorm",
+ source: "src/skills/ce-brainstorm/SKILL.md → Interaction Rules",
+ interactions: [
+ { name: "narrowing choice (one direction/priority/next step)", type: "single_select", hasOptions: true },
+ { name: "compatible set (goals/constraints/non-goals)", type: "multi_select", hasOptions: true },
+ { name: "genuinely open / diagnostic question", type: "text", hasOptions: false },
+ { name: "proceed-to-write confirmation", type: "confirm", hasOptions: false },
+ ],
+ },
+ {
+ stageId: "ideate",
+ source: "src/skills/ce-ideate/SKILL.md → Interaction Method",
+ interactions: [
+ { name: "concise single-select when natural options exist", type: "single_select", hasOptions: true },
+ { name: "open-ended ideation prompt", type: "text", hasOptions: false },
+ ],
+ },
+ {
+ stageId: "plan",
+ source: "src/skills/ce-plan/SKILL.md → Interaction Method",
+ interactions: [
+ { name: "concise single-select choice", type: "single_select", hasOptions: true },
+ { name: "clarifying free-text question (Phase 0.4 bootstrap)", type: "text", hasOptions: false },
+ ],
+ },
+];
+
+function classify(i: DeclaredInteraction): { classified: boolean; rich: boolean } {
+ const classified = isRichInteractionType(i.type);
+ if (!classified) return { classified: false, rich: false };
+ // canRenderRichly is the same predicate CeFlow uses at runtime.
+ const rich = canRenderRichly({
+ type: i.type as PlanningQuestionType,
+ options: i.hasOptions ? [{ id: "x", label: "x" }] : undefined,
+ });
+ return { classified: true, rich };
+}
+
+describe("skill-interaction audit (declared classification)", () => {
+ it("every sampled stage is a registered stage", () => {
+ for (const s of SAMPLED_STAGES) {
+ expect(getStage(s.stageId), `stage ${s.stageId} must be registered`).toBeDefined();
+ }
+ });
+
+ it("classifies every declared interaction (fails on an unclassified interaction)", () => {
+ const unclassified: string[] = [];
+ for (const stage of SAMPLED_STAGES) {
+ for (const i of stage.interactions) {
+ if (!isRichInteractionType(i.type)) {
+ unclassified.push(`${stage.stageId}:${i.name} (type=${i.type})`);
+ }
+ }
+ }
+ expect(unclassified, `unclassified interactions: ${unclassified.join(", ")}`).toHaveLength(0);
+ });
+
+ it("produces a measured rich-vs-chat coverage ratio for the sampled stages", () => {
+ let total = 0;
+ let rich = 0;
+ const perStage: Array<{ stageId: string; rich: number; total: number }> = [];
+
+ for (const stage of SAMPLED_STAGES) {
+ let sRich = 0;
+ for (const i of stage.interactions) {
+ total += 1;
+ const c = classify(i);
+ if (c.rich) {
+ rich += 1;
+ sRich += 1;
+ }
+ }
+ perStage.push({ stageId: stage.stageId, rich: sRich, total: stage.interactions.length });
+ }
+
+ const ratio = rich / total;
+
+ // Emit the produced coverage figure (visible in test output / report).
+ // eslint-disable-next-line no-console
+ console.log(
+ `[skill-interaction-audit] rich-renderable coverage: ${rich}/${total} = ${(ratio * 100).toFixed(1)}% ` +
+ `(declared classification, not live-measured)\n` +
+ perStage.map((p) => ` - ${p.stageId}: ${p.rich}/${p.total}`).join("\n"),
+ );
+
+ // The audit must compute and assert a real ratio. For the sampled stages,
+ // every declared interaction maps onto CeFlow's renderable set, so coverage
+ // is 100% — but the assertion is on the COMPUTED value, and the guard above
+ // would drop it below 1 (and the unclassified test would fail) the moment a
+ // stage declares an interaction CeFlow can't express.
+ expect(total).toBeGreaterThanOrEqual(2 + 2 + 2); // 2-3 stages, ≥2 interactions each
+ expect(ratio).toBeGreaterThan(0);
+ expect(ratio).toBeLessThanOrEqual(1);
+ expect(ratio).toBe(rich / total);
+
+ // Sanity: the four rich types CeFlow advertises are the classification set.
+ expect([...RICH_INTERACTION_TYPES].sort()).toEqual(
+ ["confirm", "multi_select", "single_select", "text"],
+ );
+ });
+
+ it("a hypothetical unrenderable interaction would be unclassified (guard proof)", () => {
+ const rogue: DeclaredInteraction = { name: "ranked drag-and-drop", type: "rank_order", hasOptions: true };
+ expect(isRichInteractionType(rogue.type)).toBe(false);
+ expect(classify(rogue).rich).toBe(false);
+ });
+});
diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx
new file mode 100644
index 0000000000..6fb383156f
--- /dev/null
+++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx
@@ -0,0 +1,317 @@
+import { useMemo, useState } from "react";
+import type { PlanningQuestion } from "@fusion/core";
+import type { CeConversationTurn, CeSession } from "../session/session-store.js";
+import { canRenderRichly } from "./ce-question-support.js";
+
+/**
+ * CeFlow — the interactive renderer (U6).
+ *
+ * Renders the four interaction types CeFlow expresses richly (`text`,
+ * `single_select`, `multi_select`, `confirm`) plus streamed `thinking`/`text`
+ * history. When a turn carries a question CeFlow CANNOT express, it degrades to
+ * a plain chat view that is VISUALLY MARKED as degraded (R8/AE1) — the stage is
+ * still completable there via a free-text answer.
+ *
+ * It does NOT import `PlanningModeModal` or any dashboard internal (KTD3 scope
+ * boundary); it only consumes the `PlanningQuestion` shape for parity.
+ */
+
+export interface CeFlowProps {
+ session?: CeSession;
+ busy?: boolean;
+ error?: string;
+ /** Submit an answer to the current question. */
+ onAnswer: (questionId: string, response: unknown) => void;
+ /** Resume an interrupted/error session. */
+ onResume?: () => void;
+ /** Back to the launcher. */
+ onClose?: () => void;
+}
+
+/** Render the agent/user conversation so far (streamed thinking/text). */
+function Transcript({ history }: { history: CeConversationTurn[] }) {
+ const visible = history.filter((t) => {
+ // Hide serialized question/answer/complete markers from the readable
+ // transcript; they are control records, not chat.
+ if (t.role === "agent" && /^\{"(question|complete)"/.test(t.text)) return false;
+ if (t.role === "user" && /^\{"answer"/.test(t.text)) return false;
+ return true;
+ });
+ if (visible.length === 0) return null;
+ return (
+
+ {visible.map((turn, i) => (
+
+ {turn.role === "agent" ? "Agent" : "You"}
+ {turn.text}
+
+ ))}
+
+ );
+}
+
+/** Rich renderer for a single supported question type. */
+function RichQuestion({
+ question,
+ disabled,
+ onAnswer,
+}: {
+ question: PlanningQuestion;
+ disabled: boolean;
+ onAnswer: (questionId: string, response: unknown) => void;
+}) {
+ const [text, setText] = useState("");
+ const [multi, setMulti] = useState([]);
+
+ const submit = (response: unknown) => onAnswer(question.id, response);
+
+ return (
+
+
{question.question}
+ {question.description ?
{question.description}
: null}
+
+ {question.type === "text" ? (
+
+ ) : null}
+
+ {question.type === "confirm" ? (
+
+ submit(true)}
+ >
+ Yes
+
+ submit(false)}
+ >
+ No
+
+
+ ) : null}
+
+ {question.type === "single_select" ? (
+
+ {(question.options ?? []).map((opt) => (
+
+ submit(opt.id)}
+ >
+ {opt.label}
+ {opt.description ? {opt.description} : null}
+
+
+ ))}
+
+ ) : null}
+
+ {question.type === "multi_select" ? (
+
{
+ e.preventDefault();
+ submit(multi);
+ }}
+ >
+
+
+ Confirm selection
+
+
+ ) : null}
+
+ );
+}
+
+/**
+ * Degraded chat fallback (R8/AE1). Used when a question can't be expressed by
+ * the rich renderer. Visibly marked as degraded; the stage is still completable
+ * because the user can answer in free text, which is submitted back through the
+ * same answer route.
+ */
+function DegradedQuestion({
+ question,
+ disabled,
+ onAnswer,
+}: {
+ question: PlanningQuestion;
+ disabled: boolean;
+ onAnswer: (questionId: string, response: unknown) => void;
+}) {
+ const [text, setText] = useState("");
+ return (
+
+
+ ⚠ Chat fallback — this prompt can't be shown as buttons here. Answer in your own words below.
+
+
{question.question}
+ {question.description ?
{question.description}
: null}
+ {Array.isArray(question.options) && question.options.length > 0 ? (
+
+ {question.options.map((opt) => (
+ {opt.label}
+ ))}
+
+ ) : null}
+
{
+ e.preventDefault();
+ if (text.trim()) onAnswer(question.id, text.trim());
+ }}
+ >
+ setText(e.target.value)}
+ rows={3}
+ />
+
+ Send
+
+
+
+ );
+}
+
+export function CeFlow(props: CeFlowProps) {
+ const { session, busy, error, onAnswer, onResume, onClose } = props;
+
+ const question = session?.currentQuestion ?? undefined;
+ const rich = useMemo(() => (question ? canRenderRichly(question) : false), [question]);
+
+ if (!session) {
+ return (
+
+
No active session.
+ {onClose ? (
+
+ Back
+
+ ) : null}
+
+ );
+ }
+
+ const status = session.status;
+ const settledTerminal = status === "completed";
+ const recoverable = status === "interrupted" || status === "error";
+
+ return (
+
+
+ {session.stage}
+
+ {status.replace("_", " ")}
+
+ {onClose ? (
+
+ Close
+
+ ) : null}
+
+
+
+
+ {busy && status !== "awaiting_input" ? (
+
+ Thinking…
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {status === "awaiting_input" && question ? (
+ rich ? (
+
+ ) : (
+
+ )
+ ) : null}
+
+ {recoverable ? (
+
+
+ Session {status}{session.error ? `: ${session.error}` : ""}.
+
+ {onResume ? (
+
+ Resume
+
+ ) : null}
+
+ ) : null}
+
+ {settledTerminal ? (
+
+
Stage complete.
+ {session.artifactPath ? (
+
+ Artifact: {session.artifactPath}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+export default CeFlow;
diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css
index 90f763c3ad..587c1383bf 100644
--- a/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css
+++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css
@@ -144,3 +144,162 @@
.ce-view[data-mobile="true"] .ce-groups {
grid-template-columns: 1fr;
}
+
+/* --- Stage launcher (U6) --- */
+.ce-launcher {
+ margin: 0.75rem 0;
+}
+.ce-launcher-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 0.5rem;
+}
+.ce-launcher-tile {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+.ce-launcher-icon {
+ flex: none;
+}
+.ce-view-start {
+ margin-left: auto;
+}
+
+/* --- CeFlow interactive renderer (U6) --- */
+.ce-flow {
+ margin: 0.75rem 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+.ce-flow-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+.ce-flow-header h3 {
+ margin: 0;
+ text-transform: capitalize;
+}
+.ce-flow-status {
+ font-size: 0.72rem;
+ opacity: 0.7;
+ text-transform: capitalize;
+}
+.ce-flow-close {
+ margin-left: auto;
+}
+.ce-flow-transcript {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ max-height: 320px;
+ overflow-y: auto;
+}
+.ce-flow-turn {
+ display: flex;
+ flex-direction: column;
+}
+.ce-flow-turn-role {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ opacity: 0.55;
+}
+.ce-flow-turn-agent .ce-flow-turn-text {
+ white-space: pre-wrap;
+}
+.ce-flow-thinking {
+ font-style: italic;
+ opacity: 0.7;
+}
+.ce-flow-question {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+.ce-flow-question-text {
+ font-weight: 600;
+ margin: 0;
+}
+.ce-flow-question-desc {
+ font-size: 0.8rem;
+ opacity: 0.75;
+ margin: 0;
+}
+.ce-flow-text {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+.ce-flow-text textarea {
+ width: 100%;
+ resize: vertical;
+}
+.ce-flow-confirm {
+ display: flex;
+ gap: 0.5rem;
+}
+.ce-flow-options {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+.ce-flow-options ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+}
+.ce-flow-option {
+ width: 100%;
+ text-align: left;
+ display: flex;
+ flex-direction: column;
+}
+.ce-flow-option-desc {
+ font-size: 0.72rem;
+ opacity: 0.7;
+}
+.ce-flow-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ cursor: pointer;
+}
+.ce-flow-error {
+ color: var(--color-danger, #d23);
+ font-size: 0.82rem;
+}
+
+/* Degraded chat fallback (R8/AE1) — must read as visibly distinct. */
+.ce-flow-degraded {
+ border: 1px dashed var(--color-warning, #c80);
+ border-radius: 6px;
+ padding: 0.6rem;
+ background: color-mix(in srgb, var(--color-warning, #c80) 8%, transparent);
+}
+.ce-flow-degraded-banner {
+ margin: 0 0 0.4rem;
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: var(--color-warning, #a60);
+}
+.ce-flow-degraded-options {
+ font-size: 0.78rem;
+ opacity: 0.8;
+ margin: 0 0 0.4rem;
+ padding-left: 1.1rem;
+}
diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.tsx
index 11e1aa721f..76e864d817 100644
--- a/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.tsx
+++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.tsx
@@ -1,11 +1,59 @@
import "./CompoundEngineeringView.css";
-import { useState } from "react";
+import { useCallback, useState } from "react";
+import * as LucideIcons from "lucide-react";
+import type { LucideIcon } from "lucide-react";
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
import { useArtifacts } from "./hooks/useArtifacts.js";
import { useViewportMode } from "./hooks/useViewportMode.js";
+import { useCeSession } from "./hooks/useCeSession.js";
import { getArtifactPreviewUrl } from "./hooks/api.js";
+import { CeFlow } from "./CeFlow.js";
+import { listStages, type CeStageDefinition } from "../session/stage-registry.js";
import type { CeArtifactEntry, CeArtifactGroup } from "../artifacts/discovery.js";
+/** Resolve a lucide icon name (from the registry) to a component, with fallback. */
+function resolveIcon(name: string): LucideIcon {
+ const icons = LucideIcons as unknown as Record;
+ return icons[name] ?? LucideIcons.Circle;
+}
+
+/** Launcher: lists exactly the registered stages (R4) and launches one. */
+function StageLauncher({
+ stages,
+ disabled,
+ onLaunch,
+}: {
+ stages: CeStageDefinition[];
+ disabled: boolean;
+ onLaunch: (stage: CeStageDefinition) => void;
+}) {
+ return (
+
+
Start a stage
+
+ {stages.map((stage) => {
+ const Icon = resolveIcon(stage.icon);
+ return (
+
+ onLaunch(stage)}
+ >
+
+ {stage.label}
+
+
+ );
+ })}
+
+
+ );
+}
+
interface CompoundEngineeringViewProps {
context?: PluginDashboardViewContext;
/** Test seam: override the active project id without a host context. */
@@ -126,6 +174,10 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
const { result, loading, error } = useArtifacts({ projectId, enabled });
const [selectedId, setSelectedId] = useState();
+ const stages = listStages();
+ const ceSession = useCeSession();
+ const [launcherOpen, setLauncherOpen] = useState(false);
+
const totalArtifacts = result?.totalArtifacts ?? 0;
const totalErrors = result?.totalErrors ?? 0;
const hasAnything = totalArtifacts > 0 || totalErrors > 0;
@@ -134,12 +186,36 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
const emptyGroups = result?.groups.filter((g) => g.entries.length === 0).length ?? 0;
const isPartial = populatedGroups > 0 && emptyGroups > 0;
- const onStart = () => {
- // Wiring to launch a stage session is U6. A placeholder affordance is fine
- // here; it makes the first-run orientation actionable without coupling U3 to
- // the session launcher.
- props.context?.addToast?.("Stage launcher arrives with the CE flow renderer (U6).", "info");
- };
+ const onStart = () => setLauncherOpen(true);
+
+ const onLaunch = useCallback(
+ (stage: CeStageDefinition) => {
+ setLauncherOpen(false);
+ void ceSession.start(stage.stageId, { message: `Start the ${stage.label} stage.`, projectId });
+ },
+ [ceSession, projectId],
+ );
+
+ const onCloseFlow = useCallback(() => ceSession.reset(), [ceSession]);
+
+ // Once a session exists, the flow renderer owns the surface until closed.
+ if (ceSession.session) {
+ return (
+
+
+
Compound Engineering
+
+
+
+ );
+ }
return (
@@ -152,8 +228,23 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
{isPartial ? " · partial" : ""}
) : null}
+ {hasAnything ? (
+
+ Start a stage
+
+ ) : null}
+ {launcherOpen ? (
+
+ ) : null}
+
+ {ceSession.error && !ceSession.session ? (
+
+ Failed to start session: {ceSession.error}
+
+ ) : null}
+
{error ? (
Failed to load artifacts: {error}
diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CeFlow.test.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CeFlow.test.tsx
new file mode 100644
index 0000000000..b577172418
--- /dev/null
+++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CeFlow.test.tsx
@@ -0,0 +1,145 @@
+import { describe, expect, it, vi } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+import type { PlanningQuestion } from "@fusion/core";
+import { CeFlow } from "../CeFlow.js";
+import type { CeSession } from "../../session/session-store.js";
+
+function makeSession(over: Partial
& { currentQuestion?: PlanningQuestion | null }): CeSession {
+ return {
+ id: "s1",
+ stage: "brainstorm",
+ status: "awaiting_input",
+ currentQuestion: null,
+ conversationHistory: [],
+ projectId: null,
+ artifactPath: null,
+ error: null,
+ turnIntervalMs: 1000,
+ lastActivityAt: Date.now(),
+ createdAt: "2026-06-02T00:00:00Z",
+ updatedAt: "2026-06-02T00:00:00Z",
+ ...over,
+ };
+}
+
+describe("CeFlow — rich question rendering + submit", () => {
+ it("renders + submits a text question", () => {
+ const onAnswer = vi.fn();
+ const q: PlanningQuestion = { id: "q-text", type: "text", question: "What's the goal?" };
+ render( );
+
+ const input = screen.getByTestId("ce-flow-text-input");
+ fireEvent.change(input, { target: { value: "ship faster" } });
+ fireEvent.click(screen.getByRole("button", { name: "Send" }));
+ expect(onAnswer).toHaveBeenCalledWith("q-text", "ship faster");
+ });
+
+ it("renders + submits a single_select question", () => {
+ const onAnswer = vi.fn();
+ const q: PlanningQuestion = {
+ id: "q-single",
+ type: "single_select",
+ question: "Pick a direction",
+ options: [
+ { id: "a", label: "Alpha" },
+ { id: "b", label: "Beta" },
+ ],
+ };
+ render( );
+ fireEvent.click(screen.getByText("Beta"));
+ expect(onAnswer).toHaveBeenCalledWith("q-single", "b");
+ });
+
+ it("renders + submits a multi_select question", () => {
+ const onAnswer = vi.fn();
+ const q: PlanningQuestion = {
+ id: "q-multi",
+ type: "multi_select",
+ question: "Which goals?",
+ options: [
+ { id: "g1", label: "Speed" },
+ { id: "g2", label: "Quality" },
+ { id: "g3", label: "Cost" },
+ ],
+ };
+ render( );
+ const boxes = screen.getByTestId("ce-flow-multi").querySelectorAll("input[type=checkbox]");
+ fireEvent.click(boxes[0]);
+ fireEvent.click(boxes[2]);
+ fireEvent.click(screen.getByTestId("ce-flow-multi-submit"));
+ expect(onAnswer).toHaveBeenCalledWith("q-multi", ["g1", "g3"]);
+ });
+
+ it("renders + submits a confirm question (both branches)", () => {
+ const onAnswer = vi.fn();
+ const q: PlanningQuestion = { id: "q-c", type: "confirm", question: "Write the doc now?" };
+ const { rerender } = render( );
+ fireEvent.click(screen.getByTestId("ce-flow-confirm-yes"));
+ expect(onAnswer).toHaveBeenLastCalledWith("q-c", true);
+ rerender( );
+ fireEvent.click(screen.getByTestId("ce-flow-confirm-no"));
+ expect(onAnswer).toHaveBeenLastCalledWith("q-c", false);
+ });
+});
+
+describe("CeFlow — degraded fallback (AE1)", () => {
+ it("falls back to a visibly-degraded chat view for an unrenderable interaction, and the stage still completes", () => {
+ const onAnswer = vi.fn();
+ // A type CeFlow cannot express richly — degrades to chat.
+ const rogue = {
+ id: "q-rogue",
+ type: "rank_order",
+ question: "Rank these by priority",
+ options: [{ id: "a", label: "A" }],
+ } as unknown as PlanningQuestion;
+
+ const { rerender } = render( );
+
+ // Visibly marked as degraded.
+ const banner = screen.getByTestId("ce-flow-degraded-banner");
+ expect(banner).toBeInTheDocument();
+ expect(screen.queryByTestId("ce-flow-question")).not.toBeInTheDocument();
+
+ // Stage is still completable: free-text answer submits through the same route.
+ fireEvent.change(screen.getByTestId("ce-flow-degraded-input"), { target: { value: "A then B" } });
+ fireEvent.click(screen.getByRole("button", { name: "Send" }));
+ expect(onAnswer).toHaveBeenCalledWith("q-rogue", "A then B");
+
+ // After the answer the orchestrator reaches `complete` → CeFlow shows done.
+ rerender(
+ ,
+ );
+ expect(screen.getByTestId("ce-flow-complete")).toBeInTheDocument();
+ expect(screen.getByTestId("ce-flow-artifact-path")).toHaveTextContent("/repo/docs/brainstorms/x.md");
+ });
+
+ it("degrades a select question that arrives with no options", () => {
+ const onAnswer = vi.fn();
+ const q: PlanningQuestion = { id: "q-empty", type: "single_select", question: "Pick", options: [] };
+ render( );
+ expect(screen.getByTestId("ce-flow-degraded")).toBeInTheDocument();
+ });
+});
+
+describe("CeFlow — lifecycle surfaces", () => {
+ it("shows thinking while a turn runs", () => {
+ render( );
+ expect(screen.getByTestId("ce-flow-thinking")).toBeInTheDocument();
+ });
+
+ it("offers resume on an interrupted session", () => {
+ const onResume = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByTestId("ce-flow-resume"));
+ expect(onResume).toHaveBeenCalled();
+ });
+});
diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/StageLauncher.test.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/StageLauncher.test.tsx
new file mode 100644
index 0000000000..c9fdf64898
--- /dev/null
+++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/StageLauncher.test.tsx
@@ -0,0 +1,83 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { DiscoveryResult } from "../../artifacts/discovery.js";
+import type { CeSession } from "../../session/session-store.js";
+import { listStages } from "../../session/stage-registry.js";
+
+// Mock the whole api module: artifacts (so the view renders empty) + session.
+const startSession = vi.fn<(stage: string, opts?: unknown) => Promise>();
+vi.mock("../hooks/api.js", () => ({
+ listArtifacts: async (): Promise => ({
+ groups: [],
+ totalArtifacts: 0,
+ totalErrors: 0,
+ }),
+ getArtifactPreviewUrl: (id: string) => `/preview/${id}`,
+ startSession: (stage: string, opts?: unknown) => startSession(stage, opts),
+ answerSession: vi.fn(),
+ resumeSession: vi.fn(),
+ getSession: vi.fn(),
+}));
+
+import { CompoundEngineeringView } from "../CompoundEngineeringView.js";
+import { __test_clearArtifactsCache } from "../hooks/useArtifacts.js";
+
+afterEach(() => {
+ __test_clearArtifactsCache();
+ startSession.mockReset();
+});
+
+function mkSession(over: Partial): CeSession {
+ return {
+ id: "s1",
+ stage: "brainstorm",
+ status: "awaiting_input",
+ currentQuestion: { id: "q1", type: "text", question: "What's the topic?" },
+ conversationHistory: [],
+ projectId: null,
+ artifactPath: null,
+ error: null,
+ turnIntervalMs: 1000,
+ lastActivityAt: Date.now(),
+ createdAt: "t",
+ updatedAt: "t",
+ ...over,
+ };
+}
+
+describe("Stage launcher (R4)", () => {
+ it("lists exactly the registered stages", async () => {
+ render( );
+ // Empty-state start affordance opens the launcher.
+ await waitFor(() => screen.getByTestId("ce-empty-state"));
+ fireEvent.click(screen.getByTestId("ce-start-action"));
+
+ const tiles = await screen.findAllByTestId("ce-launcher-stage");
+ const expected = listStages();
+ expect(tiles).toHaveLength(expected.length);
+ const renderedStages = tiles.map((t) => t.getAttribute("data-stage")).sort();
+ expect(renderedStages).toEqual(expected.map((s) => s.stageId).sort());
+ // And the labels match the registry.
+ for (const stage of expected) {
+ expect(screen.getByText(stage.label)).toBeInTheDocument();
+ }
+ });
+
+ it("launching a stage starts its session and renders CeFlow", async () => {
+ startSession.mockResolvedValue(mkSession({ stage: "plan" }));
+ render( );
+ await waitFor(() => screen.getByTestId("ce-empty-state"));
+ fireEvent.click(screen.getByTestId("ce-start-action"));
+
+ const planTile = (await screen.findAllByTestId("ce-launcher-stage")).find(
+ (t) => t.getAttribute("data-stage") === "plan",
+ )!;
+ await act(async () => {
+ fireEvent.click(planTile);
+ });
+
+ expect(startSession).toHaveBeenCalledWith("plan", expect.objectContaining({ projectId: "p1" }));
+ expect(await screen.findByTestId("ce-flow")).toBeInTheDocument();
+ expect(screen.getByTestId("ce-flow-text-input")).toBeInTheDocument();
+ });
+});
diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/ce-question-support.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/ce-question-support.ts
new file mode 100644
index 0000000000..ad49b3ba9d
--- /dev/null
+++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/ce-question-support.ts
@@ -0,0 +1,38 @@
+/**
+ * The renderable set of `CeFlow` (R8/AE1 boundary).
+ *
+ * `CeFlow` renders four interaction types richly: `text` (free-text input),
+ * `single_select`, `multi_select`, and `confirm`. Any other interaction — an
+ * unknown future question type, or a select-type question that arrives without
+ * the options it needs to render choices — is NOT expressible by the rich
+ * renderer and must degrade to the visibly-marked chat fallback.
+ *
+ * This module is the single source of truth for that boundary so the renderer
+ * and the skill-interaction audit agree on what "renderable richly" means.
+ */
+import type { PlanningQuestion, PlanningQuestionType } from "@fusion/core";
+
+/** The interaction types CeFlow renders with dedicated rich controls. */
+export const RICH_INTERACTION_TYPES: readonly PlanningQuestionType[] = [
+ "text",
+ "single_select",
+ "multi_select",
+ "confirm",
+] as const;
+
+export function isRichInteractionType(type: string): type is PlanningQuestionType {
+ return (RICH_INTERACTION_TYPES as readonly string[]).includes(type);
+}
+
+/**
+ * Whether CeFlow can render this concrete question with rich controls. A
+ * select-type question with no usable options can't present choices, so it
+ * degrades to chat even though its `type` is in the rich set.
+ */
+export function canRenderRichly(question: Pick): boolean {
+ if (!isRichInteractionType(question.type)) return false;
+ if (question.type === "single_select" || question.type === "multi_select") {
+ return Array.isArray(question.options) && question.options.length > 0;
+ }
+ return true;
+}
diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/__tests__/useCeSession.test.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/__tests__/useCeSession.test.tsx
new file mode 100644
index 0000000000..e781a47ad1
--- /dev/null
+++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/__tests__/useCeSession.test.tsx
@@ -0,0 +1,124 @@
+import { describe, expect, it, vi } from "vitest";
+import { act, render, screen } from "@testing-library/react";
+import type { PlanningQuestion } from "@fusion/core";
+import { useCeSession, type CeSessionTransport } from "../useCeSession.js";
+import type { CeSession } from "../../../session/session-store.js";
+
+function mkSession(over: Partial): CeSession {
+ return {
+ id: "s1",
+ stage: "brainstorm",
+ status: "awaiting_input",
+ currentQuestion: null,
+ conversationHistory: [],
+ projectId: null,
+ artifactPath: null,
+ error: null,
+ turnIntervalMs: 1000,
+ lastActivityAt: Date.now(),
+ createdAt: "t",
+ updatedAt: "t",
+ ...over,
+ };
+}
+
+const Q: PlanningQuestion = { id: "q1", type: "text", question: "go?" };
+
+function Harness({ transport }: { transport: CeSessionTransport }) {
+ const s = useCeSession({ transport, pollIntervalMs: 5 });
+ return (
+
+ {s.session?.status ?? "none"}
+ {s.busy ? "busy" : "idle"}
+ {s.error ?? ""}
+ void s.start("brainstorm")}>start
+ void s.answer("q1", "yes")}>answer
+ void s.resume()}>resume
+ s.reset()}>reset
+
+ );
+}
+
+describe("useCeSession lifecycle", () => {
+ it("start → awaiting_input → answer → completed", async () => {
+ const transport: CeSessionTransport = {
+ start: vi.fn(async () => mkSession({ status: "awaiting_input", currentQuestion: Q })),
+ answer: vi.fn(async () => mkSession({ status: "completed", currentQuestion: null, artifactPath: "/a.md" })),
+ resume: vi.fn(async () => mkSession({})),
+ get: vi.fn(async () => mkSession({})),
+ };
+ render( );
+
+ await act(async () => {
+ screen.getByText("start").click();
+ });
+ expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
+
+ await act(async () => {
+ screen.getByText("answer").click();
+ });
+ expect(screen.getByTestId("status")).toHaveTextContent("completed");
+ expect(transport.answer).toHaveBeenCalledWith("s1", "q1", "yes");
+ });
+
+ it("polls while active and stops once settled", async () => {
+ let calls = 0;
+ const get = vi.fn(async () => {
+ calls += 1;
+ return calls >= 2 ? mkSession({ status: "awaiting_input", currentQuestion: Q }) : mkSession({ status: "active" });
+ });
+ const transport: CeSessionTransport = {
+ start: vi.fn(async () => mkSession({ status: "active", currentQuestion: null })),
+ answer: vi.fn(),
+ resume: vi.fn(),
+ get,
+ };
+ render( );
+ await act(async () => {
+ screen.getByText("start").click();
+ });
+ expect(screen.getByTestId("status")).toHaveTextContent("active");
+
+ // Let the poll interval fire and converge to awaiting_input.
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 40));
+ });
+ expect(get).toHaveBeenCalled();
+ expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
+ });
+
+ it("surfaces a start error", async () => {
+ const transport: CeSessionTransport = {
+ start: vi.fn(async () => {
+ throw new Error("boom");
+ }),
+ answer: vi.fn(),
+ resume: vi.fn(),
+ get: vi.fn(),
+ };
+ render( );
+ await act(async () => {
+ screen.getByText("start").click();
+ });
+ expect(screen.getByTestId("err")).toHaveTextContent("boom");
+ });
+
+ it("resume transitions an interrupted session", async () => {
+ const transport: CeSessionTransport = {
+ start: vi.fn(async () => mkSession({ status: "interrupted", currentQuestion: Q })),
+ answer: vi.fn(),
+ resume: vi.fn(async () => mkSession({ status: "awaiting_input", currentQuestion: Q })),
+ get: vi.fn(async () => mkSession({ status: "interrupted", currentQuestion: Q })),
+ };
+ render( );
+ await act(async () => {
+ screen.getByText("start").click();
+ });
+ expect(screen.getByTestId("status")).toHaveTextContent("interrupted");
+ await act(async () => {
+ screen.getByText("resume").click();
+ });
+ expect(transport.resume).toHaveBeenCalledWith("s1");
+ expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
+ });
+});
diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts
index 8f2fca8114..2e4424c26d 100644
--- a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts
+++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts
@@ -1,4 +1,5 @@
import type { DiscoveryResult } from "../../artifacts/discovery.js";
+import type { CeSession } from "../../session/session-store.js";
const BASE = "/api/plugins/fusion-plugin-compound-engineering";
@@ -43,3 +44,46 @@ export async function getArtifact(
export function getArtifactPreviewUrl(id: string, projectId?: string): string {
return `${BASE}/artifacts/${encodeURIComponent(id)}/preview.html${qp({ projectId })}`;
}
+
+// --- Interactive CE session routes (polling transport, U5/U6) ---------------
+
+/** Start a stage session. Returns the freshly-created session (after one turn). */
+export async function startSession(
+ stage: string,
+ opts: { message?: string; projectId?: string } = {},
+): Promise {
+ const data = await request<{ session: CeSession }>(`/sessions`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ stage, message: opts.message ?? "", projectId: opts.projectId }),
+ });
+ return data.session;
+}
+
+/** Submit an answer to the awaiting question and advance the session. */
+export async function answerSession(
+ sessionId: string,
+ questionId: string,
+ response: unknown,
+): Promise {
+ const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}/answer`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ questionId, response }),
+ });
+ return data.session;
+}
+
+/** Resume an interrupted/error/awaiting session back to its current question. */
+export async function resumeSession(sessionId: string): Promise {
+ const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}/resume`, {
+ method: "POST",
+ });
+ return data.session;
+}
+
+/** Poll the current persisted session state. */
+export async function getSession(sessionId: string): Promise {
+ const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}`);
+ return data.session;
+}
diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useCeSession.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useCeSession.ts
new file mode 100644
index 0000000000..b025eb426c
--- /dev/null
+++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useCeSession.ts
@@ -0,0 +1,159 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { CeSession, CeSessionStatus } from "../../session/session-store.js";
+import {
+ answerSession as answerSessionApi,
+ getSession as getSessionApi,
+ resumeSession as resumeSessionApi,
+ startSession as startSessionApi,
+} from "./api.js";
+
+/**
+ * Injectable transport so component tests can drive the lifecycle without a
+ * network. Defaults to the real polling routes.
+ */
+export interface CeSessionTransport {
+ start(stage: string, opts: { message?: string; projectId?: string }): Promise;
+ answer(sessionId: string, questionId: string, response: unknown): Promise;
+ resume(sessionId: string): Promise;
+ get(sessionId: string): Promise;
+}
+
+const defaultTransport: CeSessionTransport = {
+ start: (stage, opts) => startSessionApi(stage, opts),
+ answer: (id, qid, response) => answerSessionApi(id, qid, response),
+ resume: (id) => resumeSessionApi(id),
+ get: (id) => getSessionApi(id),
+};
+
+/** Statuses where no further polling is useful (settled or waiting on the user). */
+const SETTLED: ReadonlySet = new Set([
+ "awaiting_input",
+ "completed",
+ "error",
+ "interrupted",
+]);
+
+export interface UseCeSessionOptions {
+ /** Poll interval (ms) while a turn is running (status active/launching). */
+ pollIntervalMs?: number;
+ transport?: CeSessionTransport;
+}
+
+export interface UseCeSessionResult {
+ session?: CeSession;
+ /** True while a request (start/answer/resume) is in flight. */
+ busy: boolean;
+ error?: string;
+ start(stage: string, opts?: { message?: string; projectId?: string }): Promise;
+ answer(questionId: string, response: unknown): Promise;
+ resume(): Promise;
+ reset(): void;
+}
+
+/**
+ * Drive a single CE stage session through its lifecycle over the polling
+ * routes: start → (poll while a turn runs) → render question → submit answer →
+ * continue → completed/error; resume an interrupted/error session.
+ *
+ * The session routes already run one turn synchronously per request and return
+ * the post-turn state, so the common path settles immediately. Polling is the
+ * fallback for a session left `active`/`launching` (e.g. recovered from another
+ * process), honoring U5's client-polling transport.
+ */
+export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionResult {
+ const transport = options.transport ?? defaultTransport;
+ const pollIntervalMs = options.pollIntervalMs ?? 1500;
+
+ const [session, setSession] = useState();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState();
+
+ // Keep the live id for the polling effect without re-subscribing on every
+ // session field change.
+ const sessionIdRef = useRef(undefined);
+ const mounted = useRef(true);
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+
+ const apply = useCallback((next: CeSession) => {
+ sessionIdRef.current = next.id;
+ if (mounted.current) setSession(next);
+ }, []);
+
+ const run = useCallback(
+ async (op: () => Promise) => {
+ setBusy(true);
+ setError(undefined);
+ try {
+ const next = await op();
+ apply(next);
+ } catch (err) {
+ if (mounted.current) setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ if (mounted.current) setBusy(false);
+ }
+ },
+ [apply],
+ );
+
+ const start = useCallback(
+ (stage: string, opts: { message?: string; projectId?: string } = {}) =>
+ run(() => transport.start(stage, opts)),
+ [run, transport],
+ );
+
+ const answer = useCallback(
+ (questionId: string, response: unknown) => {
+ const id = sessionIdRef.current;
+ if (!id) return Promise.resolve();
+ return run(() => transport.answer(id, questionId, response));
+ },
+ [run, transport],
+ );
+
+ const resume = useCallback(() => {
+ const id = sessionIdRef.current;
+ if (!id) return Promise.resolve();
+ return run(() => transport.resume(id));
+ }, [run, transport]);
+
+ const reset = useCallback(() => {
+ sessionIdRef.current = undefined;
+ setSession(undefined);
+ setError(undefined);
+ setBusy(false);
+ }, []);
+
+ // Poll while a turn is mid-flight (active/launching) and we are not already
+ // issuing a request. Stops as soon as the session settles.
+ const status = session?.status;
+ useEffect(() => {
+ const id = sessionIdRef.current;
+ if (!id || busy) return;
+ if (!status || SETTLED.has(status)) return;
+
+ let cancelled = false;
+ const timer = setInterval(() => {
+ transport
+ .get(id)
+ .then((next) => {
+ if (!cancelled) apply(next);
+ })
+ .catch((err: unknown) => {
+ if (!cancelled && mounted.current) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ });
+ }, pollIntervalMs);
+ return () => {
+ cancelled = true;
+ clearInterval(timer);
+ };
+ }, [status, busy, transport, apply, pollIntervalMs]);
+
+ return { session, busy, error, start, answer, resume, reset };
+}
diff --git a/plugins/fusion-plugin-compound-engineering/src/session/stage-registry.ts b/plugins/fusion-plugin-compound-engineering/src/session/stage-registry.ts
index 9ad3b792ff..2703a4175b 100644
--- a/plugins/fusion-plugin-compound-engineering/src/session/stage-registry.ts
+++ b/plugins/fusion-plugin-compound-engineering/src/session/stage-registry.ts
@@ -1,11 +1,13 @@
/**
- * Minimal internal stage registry (U5 slice).
+ * Generic stage registry (KTD6).
*
- * The full registry + presentation metadata is U6. Here we keep ONLY the data
- * the orchestrator needs to launch a stage by id: which bundled `ce-*` skill it
- * loads, and where its `complete` artifact is written (R10). Adding a stage is a
- * data entry in this map — no new route or store code (proved by the
- * "second stage through the same orchestrator" test).
+ * A single map takes each stage → `{ skillId, artifact location/glob,
+ * presentation metadata }`. The orchestrator needs `skillId` +
+ * `artifactLocation` to launch a stage and write its `complete` output (R10);
+ * the dashboard needs `icon` + `label` (+ optional `artifactGlob`) to list and
+ * render the launcher (R4). Adding a stage is a data entry in this map — no new
+ * route, store, or screen code. "Which stages render richly vs. fall back to
+ * chat" is measured by the U6 skill-interaction audit, not assumed here.
*/
export interface CeStageDefinition {
@@ -19,17 +21,60 @@ export interface CeStageDefinition {
* timestamped file inside that directory; otherwise it writes that exact file.
*/
artifactLocation: string;
+ /**
+ * lucide-react icon name for the launcher tile (a string, resolved to a
+ * component in the dashboard so the registry stays a pure-data module with no
+ * React import). Must match an export of `lucide-react`.
+ */
+ icon: string;
+ /** Human label for the launcher tile. */
+ label: string;
+ /**
+ * Optional glob (project-root-relative) describing where this stage's
+ * artifacts live for hub discovery. Defaults are derived from
+ * `artifactLocation` when omitted.
+ */
+ artifactGlob?: string;
}
/**
* The first registration slice. Locations mirror where the real ce-* skills
* write today (STRATEGY.md, docs/ideation/, docs/brainstorms/, docs/plans/).
+ * Icons are lucide-react export names.
*/
const STAGE_DEFINITIONS: CeStageDefinition[] = [
- { stageId: "strategy", skillId: "ce-strategy", artifactLocation: "STRATEGY.md" },
- { stageId: "ideate", skillId: "ce-ideate", artifactLocation: "docs/ideation/" },
- { stageId: "brainstorm", skillId: "ce-brainstorm", artifactLocation: "docs/brainstorms/" },
- { stageId: "plan", skillId: "ce-plan", artifactLocation: "docs/plans/" },
+ {
+ stageId: "strategy",
+ skillId: "ce-strategy",
+ artifactLocation: "STRATEGY.md",
+ icon: "Compass",
+ label: "Strategy",
+ artifactGlob: "STRATEGY.md",
+ },
+ {
+ stageId: "ideate",
+ skillId: "ce-ideate",
+ artifactLocation: "docs/ideation/",
+ icon: "Lightbulb",
+ label: "Ideate",
+ artifactGlob: "docs/ideation/**/*.md",
+ },
+ {
+ stageId: "brainstorm",
+ skillId: "ce-brainstorm",
+ artifactLocation: "docs/brainstorms/",
+ icon: "Sparkles",
+ label: "Brainstorm",
+ artifactGlob: "docs/brainstorms/**/*.md",
+ },
+ {
+ stageId: "plan",
+ skillId: "ce-plan",
+ artifactLocation: "docs/plans/",
+ icon: "ListChecks",
+ label: "Plan",
+ artifactGlob: "docs/plans/**/*.md",
+ },
];
const REGISTRY = new Map(STAGE_DEFINITIONS.map((s) => [s.stageId, s]));