diff --git a/.changeset/clean-compound-pipeline.md b/.changeset/clean-compound-pipeline.md new file mode 100644 index 0000000000..db3e98db3b --- /dev/null +++ b/.changeset/clean-compound-pipeline.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Clarify Compound Engineering progress and preserve one plan from brainstorm through delivery. +category: feature +dev: Adds a stage rail, safer session controls, explicit choice confirmation, and terminal Work progression. diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-registry.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-registry.test.ts index dba3d47642..6545ad778a 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-registry.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-registry.test.ts @@ -1,17 +1,28 @@ import { describe, expect, it } from "vitest"; import * as LucideIcons from "lucide-react"; -import { getStage, listStages } from "../session/stage-registry.js"; +import { getStage, listPipelineStages, listStages } from "../session/stage-registry.js"; import { nextStageAfter } from "../sync/reconciler.js"; describe("compound engineering stage registry", () => { - it("keeps the linear pipeline order unchanged and appends debug at the tail", () => { + it("keeps debug launchable while excluding it from the automatic pipeline", () => { const stageIds = listStages().map((stage) => stage.stageId); + const pipelineStageIds = listPipelineStages().map((stage) => stage.stageId); expect(stageIds.slice(0, 5)).toEqual(["strategy", "ideate", "brainstorm", "plan", "work"]); expect(stageIds.at(-1)).toBe("debug"); expect(stageIds.filter((stageId) => stageId === "debug")).toHaveLength(1); expect(stageIds.indexOf("plan")).toBeLessThan(stageIds.indexOf("work")); expect(stageIds.indexOf("work")).toBeLessThan(stageIds.indexOf("debug")); + expect(pipelineStageIds).toEqual(["strategy", "ideate", "brainstorm", "plan", "work"]); + }); + + it("advances through every automatic transition and treats work as terminal", () => { + expect(nextStageAfter("strategy")).toBe("ideate"); + expect(nextStageAfter("ideate")).toBe("brainstorm"); + expect(nextStageAfter("brainstorm")).toBe("plan"); + expect(nextStageAfter("plan")).toBe("work"); + expect(nextStageAfter("work")).toBeUndefined(); + expect(nextStageAfter("debug")).toBeUndefined(); }); it("aliases brainstorm to unified docs/plans artifacts without renaming the stage or skill", () => { @@ -42,6 +53,7 @@ describe("compound engineering stage registry", () => { artifactGlob: "docs/debug/**/*.md", icon: "Bug", label: "Debug", + participatesInPipeline: false, }); expect((LucideIcons as unknown as Record)[stage!.icon]).toBeTruthy(); }); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-skill-loading.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-skill-loading.test.ts index ae8cc38421..d8b268ee71 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-skill-loading.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-skill-loading.test.ts @@ -1,9 +1,10 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CreateInteractiveAiSessionFactory } from "@fusion/core"; import { CeOrchestrator, warnIfStageSkillMissing } from "../session/orchestrator.js"; +import { getCeSessionStore } from "../session/session-store.js"; import { listStages } from "../session/stage-registry.js"; import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js"; @@ -66,4 +67,118 @@ describe("CE stage skill loading session options", () => { rmSync(missingRoot, { recursive: true, force: true }); } }); + + it("enriches a completed brainstorm artifact in place when plan starts for the same project", async () => { + const capturedOptions: Parameters[0][] = []; + const factory: CreateInteractiveAiSessionFactory = vi.fn(async (options) => { + capturedOptions.push(options); + const artifact = options.requestedSkillNames?.includes("ce-brainstorm") + ? "---\nartifact_contract: ce-unified-plan/v1\nartifact_readiness: requirements-only\n---\n# Requirements\n" + : "---\nartifact_contract: ce-unified-plan/v1\nartifact_readiness: implementation-ready\nexecution: code\n---\n# Plan\n"; + return { session: makeScriptedSession([{ type: "complete", data: { artifact } }]) }; + }); + const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); + + const brainstorm = await orch.start("brainstorm", { openingMessage: "Frame it", projectId: "project-a" }); + const plan = await orch.start("plan", { openingMessage: "Plan it", projectId: "project-a" }); + + expect(plan.session.artifactPath).toBe(brainstorm.session.artifactPath); + expect(readFileSync(plan.session.artifactPath!, "utf8")).toContain("artifact_readiness: implementation-ready"); + expect(capturedOptions[1].systemPrompt).toContain(brainstorm.session.artifactPath!); + expect(capturedOptions[1].systemPrompt).toContain("enrich that exact artifact in place"); + }); + + it("uses the explicitly selected brainstorm predecessor when several are complete", async () => { + const factory: CreateInteractiveAiSessionFactory = vi.fn(async (options) => { + const artifact = options.requestedSkillNames?.includes("ce-brainstorm") + ? "---\nartifact_contract: ce-unified-plan/v1\nartifact_readiness: requirements-only\n---\n# Requirements\n" + : "---\nartifact_contract: ce-unified-plan/v1\nartifact_readiness: implementation-ready\n---\n# Plan\n"; + return { session: makeScriptedSession([{ type: "complete", data: { artifact } }]) }; + }); + const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); + const older = await orch.start("brainstorm", { openingMessage: "Older", projectId: "project-a" }); + const newer = await orch.start("brainstorm", { openingMessage: "Newer", projectId: "project-a" }); + + const plan = await orch.start("plan", { + openingMessage: "Plan the selected requirements", + projectId: "project-a", + sourceSessionId: older.session.id, + }); + + expect(plan.session.artifactPath).toBe(older.session.artifactPath); + expect(readFileSync(older.session.artifactPath!, "utf8")).toContain("implementation-ready"); + expect(readFileSync(newer.session.artifactPath!, "utf8")).toContain("requirements-only"); + }); + + it("does not reuse an already implementation-ready brainstorm artifact", async () => { + const factory: CreateInteractiveAiSessionFactory = vi.fn(async (options) => { + const artifact = options.requestedSkillNames?.includes("ce-brainstorm") + ? "---\nartifact_contract: ce-unified-plan/v1\nartifact_readiness: requirements-only\n---\n# Requirements\n" + : "---\nartifact_contract: ce-unified-plan/v1\nartifact_readiness: implementation-ready\n---\n# Plan\n"; + return { session: makeScriptedSession([{ type: "complete", data: { artifact } }]) }; + }); + const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); + const brainstorm = await orch.start("brainstorm", { openingMessage: "Frame it", projectId: "project-a" }); + const firstPlan = await orch.start("plan", { openingMessage: "Plan it", projectId: "project-a" }); + const finalized = readFileSync(firstPlan.session.artifactPath!, "utf8"); + + const secondPlan = await orch.start("plan", { openingMessage: "Plan again", projectId: "project-a" }); + + expect(secondPlan.session.artifactPath).not.toBe(brainstorm.session.artifactPath); + expect(readFileSync(brainstorm.session.artifactPath!, "utf8")).toBe(finalized); + }); + + it("preserves requirements when Plan completion is not implementation-ready", async () => { + const factory: CreateInteractiveAiSessionFactory = vi.fn(async (options) => { + const artifact = options.requestedSkillNames?.includes("ce-brainstorm") + ? "---\nartifact_contract: ce-unified-plan/v1\nartifact_readiness: requirements-only\n---\n# Requirements\n" + : "# malformed plan"; + return { session: makeScriptedSession([{ type: "complete", data: { artifact } }]) }; + }); + const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); + const brainstorm = await orch.start("brainstorm", { openingMessage: "Frame it", projectId: "project-a" }); + const original = readFileSync(brainstorm.session.artifactPath!, "utf8"); + + const plan = await orch.start("plan", { openingMessage: "Plan it", projectId: "project-a" }); + + expect(plan.session.status).toBe("error"); + expect(readFileSync(brainstorm.session.artifactPath!, "utf8")).toBe(original); + }); + + it("does not reuse a brainstorm artifact from another project", async () => { + const factory: CreateInteractiveAiSessionFactory = vi.fn(async (options) => ({ + session: makeScriptedSession([{ + type: "complete", + data: { artifact: `# ${options.requestedSkillNames?.[0]}` }, + }]), + })); + const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); + + const brainstorm = await orch.start("brainstorm", { openingMessage: "Frame it", projectId: "project-a" }); + const plan = await orch.start("plan", { openingMessage: "Plan it", projectId: "project-b" }); + + expect(plan.session.artifactPath).not.toBe(brainstorm.session.artifactPath); + }); + + it("rejects persisted brainstorm handoffs outside docs/plans", async () => { + const outsideRoot = mkdtempSync(join(tmpdir(), "ce-outside-plan-")); + const outsideArtifact = join(outsideRoot, "requirements.md"); + writeFileSync(outsideArtifact, "do not overwrite", "utf8"); + const store = getCeSessionStore(h.ctx); + const seeded = store.create({ stage: "brainstorm", projectId: "project-a", artifactPath: outsideArtifact }); + store.update(seeded.id, { status: "completed" }); + const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({ + session: makeScriptedSession([{ type: "complete", data: { artifact: "# Safe plan" } }]), + })); + const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); + + try { + const plan = await orch.start("plan", { openingMessage: "Plan it", projectId: "project-a" }); + + expect(plan.session.artifactPath).not.toBe(outsideArtifact); + expect(readFileSync(outsideArtifact, "utf8")).toBe("do not overwrite"); + } finally { + rmSync(outsideRoot, { recursive: true, force: true }); + } + }); }); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts index 76987fdf3e..89f0335f9d 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts @@ -183,6 +183,23 @@ describe("U8 reconciler (convergence + outbound)", () => { expect(state.currentStage).toBe("work"); }); + it("completing work terminates the automatic pipeline without creating a debug task", async () => { + const { cePipelineId, task } = await landPipeline("work"); + const before = await taskStore.listTasks(); + + await moveTo(task.id, "done"); + const result = await reconcileCePipelines(ctx); + + expect(result.advanced).toBe(1); + expect(result.tasksCreated).toBe(0); + expect(getCePipelineStore(ctx).getState(cePipelineId)).toMatchObject({ + currentStage: "work", + status: "completed", + }); + expect(await taskStore.listTasks()).toHaveLength(before.length); + expect(getCePipelineStore(ctx).listByPipeline(cePipelineId).some((link) => link.ceStageId === "debug")).toBe(false); + }); + it("outbound: advancing the pipeline propagates a NEW next-stage board task", async () => { const { cePipelineId, task } = await landPipeline("plan"); const before = (await taskStore.listTasks()).length; diff --git a/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts b/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts index 4f3153b364..7c6abefb48 100644 --- a/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as realFs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { registerStage } from "../../session/stage-registry.js"; // Mock node:fs so we can observe/inject behaviour around readFileSync and // accessSync without relying on vi.spyOn (ESM namespace exports are not @@ -44,35 +45,39 @@ describe("discoverArtifacts", () => { vi.restoreAllMocks(); }); - it("returns grouped artifacts from legacy brainstorm and unified plan locations (happy path)", () => { + it("returns registry-backed pipeline artifacts plus explicit knowledge collections", () => { root = makeRepo(); writeFileSync(join(root, "STRATEGY.md"), "# Strategy"); writeFileSync(join(root, "CONCEPTS.md"), "# Concepts"); mkdirSync(join(root, "docs/ideation"), { recursive: true }); writeFileSync(join(root, "docs/ideation/a.md"), "ideation a"); writeFileSync(join(root, "docs/ideation/b.md"), "ideation b"); - mkdirSync(join(root, "docs/brainstorms"), { recursive: true }); - writeFileSync(join(root, "docs/brainstorms/x.md"), "brainstorm x"); mkdirSync(join(root, "docs/plans"), { recursive: true }); writeFileSync(join(root, "docs/plans/plan1.md"), "plan 1"); + mkdirSync(join(root, "docs/work"), { recursive: true }); + writeFileSync(join(root, "docs/work/work.md"), "work"); + mkdirSync(join(root, "docs/debug"), { recursive: true }); + writeFileSync(join(root, "docs/debug/debug.md"), "debug"); mkdirSync(join(root, "docs/solutions"), { recursive: true }); writeFileSync(join(root, "docs/solutions/sol.md"), "solution"); const result = discoverArtifacts(root); const byStage = Object.fromEntries(result.groups.map((g) => [g.stage, g])); - expect(result.totalArtifacts).toBe(7); + expect(result.totalArtifacts).toBe(8); expect(result.totalErrors).toBe(0); expect(byStage.strategy.entries).toHaveLength(1); expect(byStage.concepts.entries).toHaveLength(1); - expect(byStage.ideation.entries).toHaveLength(2); - expect(byStage.brainstorm.entries).toHaveLength(1); - expect(byStage.brainstorm.entries[0]).toMatchObject({ path: "docs/brainstorms/x.md" }); + expect(byStage.ideate.entries).toHaveLength(2); expect(byStage.plan.entries).toHaveLength(1); expect(byStage.plan.entries[0]).toMatchObject({ path: "docs/plans/plan1.md" }); + expect(byStage.plan.label).toBe("Brainstorm / Plan"); + expect(byStage.work.entries[0]).toMatchObject({ path: "docs/work/work.md" }); + expect(byStage.debug.entries[0]).toMatchObject({ path: "docs/debug/debug.md" }); expect(byStage.solution.entries).toHaveLength(1); // Every group present is flagged present. - expect(byStage.ideation.present).toBe(true); + expect(byStage.ideate.present).toBe(true); + expect(byStage.brainstorm).toBeUndefined(); // All entries are artifacts in the happy path. expect(result.groups.flatMap((g) => g.entries).every((e) => e.kind === "artifact")).toBe(true); }); @@ -107,7 +112,7 @@ describe("discoverArtifacts", () => { expect(populated.map((g) => g.stage).sort()).toEqual(["plan", "strategy"]); expect(empty.length).toBeGreaterThan(0); // Empty groups are still present in the result so the hub can render them. - expect(result.groups).toHaveLength(6); + expect(result.groups).toHaveLength(7); }); it("returns an all-empty result when nothing is present (first-run)", () => { @@ -118,10 +123,8 @@ describe("discoverArtifacts", () => { expect(result.groups.every((g) => g.entries.length === 0 && !g.present)).toBe(true); }); - it("classifies unified plan readiness metadata while keeping legacy files valid without frontmatter", () => { + it("classifies both readiness states in the single unified plan collection", () => { root = makeRepo(); - mkdirSync(join(root, "docs/brainstorms"), { recursive: true }); - writeFileSync(join(root, "docs/brainstorms/legacy.md"), "# Legacy brainstorm"); mkdirSync(join(root, "docs/plans"), { recursive: true }); writeFileSync( join(root, "docs/plans/requirements.md"), @@ -133,18 +136,10 @@ describe("discoverArtifacts", () => { ); const result = discoverArtifacts(root); - const brainstorm = result.groups.find((g) => g.stage === "brainstorm")!; const plan = result.groups.find((g) => g.stage === "plan")!; - const legacy = brainstorm.entries[0]; const requirementsOnly = plan.entries.find((e) => e.name === "requirements.md"); const implementationReady = plan.entries.find((e) => e.name === "implementation.md"); - expect(legacy).toMatchObject({ - kind: "artifact", - artifactContract: null, - artifactReadiness: null, - productContractSource: null, - }); expect(requirementsOnly).toMatchObject({ kind: "artifact", artifactContract: "ce-unified-plan/v1", @@ -278,6 +273,26 @@ describe("discoverArtifacts", () => { expect(readArtifactById(root, "plan:../../secrets.md")).toBeUndefined(); }); + + it("discovers artifacts for a runtime-registered stage", () => { + registerStage({ + stageId: "publish-check", + order: 550, + skillId: "ce-publish-check", + artifactLocation: "docs/publish-check/", + icon: "FileCheck", + label: "Publish Check", + }); + root = makeRepo(); + mkdirSync(join(root, "docs/publish-check"), { recursive: true }); + writeFileSync(join(root, "docs/publish-check/result.md"), "# Ready"); + + const result = discoverArtifacts(root); + expect(result.groups.find((group) => group.stage === "publish-check")).toMatchObject({ + label: "Publish Check", + entries: [expect.objectContaining({ path: "docs/publish-check/result.md" })], + }); + }); }); describe("readArtifactById", () => { diff --git a/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts b/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts index 1c1c7e36fb..3df21c7f33 100644 --- a/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts +++ b/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts @@ -10,6 +10,7 @@ import { realpathSync, } from "node:fs"; import { isAbsolute, join, relative, sep } from "node:path"; +import { listStages } from "../session/stage-registry.js"; /** * CE artifact discovery (U3). @@ -21,17 +22,19 @@ import { isAbsolute, join, relative, sep } from "node:path"; * them. An artifact that cannot be read or is malformed is represented as an * `error` entry rather than crashing the scan or being silently dropped. * - * Locations (per the plan): STRATEGY.md, docs/ideation/, docs/brainstorms/, - * docs/plans/, docs/solutions/, CONCEPTS.md. + * Stage locations come from the registry. Solutions and Concepts remain + * explicit knowledge collections because they are not interactive stages. */ export type CeArtifactStage = | "strategy" - | "ideation" - | "brainstorm" + | "ideate" | "plan" + | "work" + | "debug" | "solution" - | "concepts"; + | "concepts" + | (string & {}); /** Whether a conventional location is a single file or a directory of files. */ type LocationKind = "file" | "directory"; @@ -50,14 +53,31 @@ interface ConventionalLocation { * scanner reads ONLY these paths (and, for directories, their immediate `.md` * children). Nothing outside this list is opened. */ -export const CONVENTIONAL_LOCATIONS: readonly ConventionalLocation[] = [ - { stage: "strategy", label: "Strategy", path: "STRATEGY.md", kind: "file" }, - { stage: "ideation", label: "Ideation", path: "docs/ideation", kind: "directory" }, - { stage: "brainstorm", label: "Brainstorms", path: "docs/brainstorms", kind: "directory" }, - { stage: "plan", label: "Plans", path: "docs/plans", kind: "directory" }, - { stage: "solution", label: "Solutions", path: "docs/solutions", kind: "directory" }, - { stage: "concepts", label: "Concepts", path: "CONCEPTS.md", kind: "file" }, -]; +/* +FNXC:CompoundEngineeringArtifacts 2026-07-10-12:00: +The artifact hub must follow registered stage output locations so Work and Debug remain discoverable as the pipeline evolves. Brainstorm and Plan share one durable docs/plans collection; exposing the removed docs/brainstorms location or two groups for the same path misrepresents the unified-plan contract. +*/ +function stageLocations(): ConventionalLocation[] { + return listStages().filter((definition) => definition.stageId !== "brainstorm").map((definition) => { + const path = definition.artifactLocation.replace(/\/$/, ""); + return { + stage: definition.stageId, + label: definition.stageId === "plan" ? "Brainstorm / Plan" : definition.label, + path, + kind: definition.artifactLocation.endsWith("/") ? "directory" : "file", + }; + }); +} + +function conventionalLocations(): ConventionalLocation[] { + return [ + ...stageLocations(), + { stage: "solution", label: "Solutions", path: "docs/solutions", kind: "directory" }, + { stage: "concepts", label: "Concepts", path: "CONCEPTS.md", kind: "file" }, + ]; +} + +export const CONVENTIONAL_LOCATIONS: readonly ConventionalLocation[] = conventionalLocations(); /** A discovered, readable artifact. */ export interface CeArtifact { @@ -376,7 +396,7 @@ function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGr */ export function discoverArtifacts(projectRoot: string): DiscoveryResult { const root = projectRoot; - const groups = CONVENTIONAL_LOCATIONS.map((loc) => discoverLocation(root, loc)); + const groups = conventionalLocations().map((loc) => discoverLocation(root, loc)); let totalArtifacts = 0; let totalErrors = 0; for (const g of groups) { @@ -402,7 +422,7 @@ export function readArtifactById( if (sepIdx <= 0) return undefined; const stage = id.slice(0, sepIdx) as CeArtifactStage; const relPath = id.slice(sepIdx + 1); - const loc = CONVENTIONAL_LOCATIONS.find((l) => l.stage === stage); + const loc = conventionalLocations().find((l) => l.stage === stage); if (!loc) return undefined; const locationAbs = join(projectRoot, loc.path); diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx index 0cd7b76b28..a77ccbc095 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { Trash2 } from "lucide-react"; import type { PlanningQuestion } from "@fusion/core"; import type { CeActivityTurn, CeConversationTurn, CeSession } from "../session/session-store.js"; +import { getStage } from "../session/stage-registry.js"; import { canRenderRichly } from "./ce-question-support.js"; /** @@ -38,6 +39,27 @@ export interface CeFlowProps { onCancel?: () => void; /** Back to the launcher. */ onClose?: () => void; + /** Open the completed stage artifact. */ + onOpenArtifact?: (artifactPath: string) => void; + /** Pipeline stage available after this session. Debug is intentionally manual-only. */ + nextStageId?: string; + /** Start the next pipeline stage. */ + onStartNextStage?: (stageId: string) => void; +} + +/* +FNXC:CompoundEngineeringFlow 2026-07-10-22:51: +The active flow uses registry labels, explicit choice confirmation, secondary optional guidance, accessible async status announcements, and hierarchical recovery/completion actions. Debug remains a manual investigation and must never appear as automatic next-stage progression. +*/ + +function humanizeIdentifier(value: string): string { + return value + .replace(/[-_]+/g, " ") + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function stageLabel(stageId: string): string { + return getStage(stageId)?.label ?? humanizeIdentifier(stageId); } // ── Transcript parsing ─────────────────────────────────────────────────────── @@ -270,6 +292,7 @@ function RichQuestion({ onAnswer: (questionId: string, response: unknown) => void; }) { const [text, setText] = useState(""); + const [single, setSingle] = useState(null); const [multi, setMulti] = useState([]); const submit = (response: unknown) => onAnswer(question.id, response); @@ -326,22 +349,36 @@ function RichQuestion({ ) : null} {question.type === "single_select" ? ( -
    - {(question.options ?? []).map((opt) => ( -
  • - -
  • - ))} -
+
+
    + {(question.options ?? []).map((opt) => ( +
  • + +
  • + ))} +
+ +
) : null} {question.type === "multi_select" ? ( @@ -350,7 +387,7 @@ function RichQuestion({ data-testid="ce-flow-multi" onSubmit={(e) => { e.preventDefault(); - submit(multi); + if (multi.length > 0) submit(multi); }} >
    @@ -377,7 +414,12 @@ function RichQuestion({ ); })}
- @@ -483,9 +525,10 @@ function QuestionPanel({ )} {showGuidance ? ( -
+
+ Add guidance (optional)