feat(compound-engineering): clarify the pipeline flow

This commit is contained in:
gsxdsm
2026-07-10 23:28:46 -07:00
parent 05d30ffee7
commit f66bfae6eb
20 changed files with 920 additions and 132 deletions

View File

@@ -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.

View File

@@ -1,17 +1,28 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import * as LucideIcons from "lucide-react"; 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"; import { nextStageAfter } from "../sync/reconciler.js";
describe("compound engineering stage registry", () => { 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 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.slice(0, 5)).toEqual(["strategy", "ideate", "brainstorm", "plan", "work"]);
expect(stageIds.at(-1)).toBe("debug"); expect(stageIds.at(-1)).toBe("debug");
expect(stageIds.filter((stageId) => stageId === "debug")).toHaveLength(1); expect(stageIds.filter((stageId) => stageId === "debug")).toHaveLength(1);
expect(stageIds.indexOf("plan")).toBeLessThan(stageIds.indexOf("work")); expect(stageIds.indexOf("plan")).toBeLessThan(stageIds.indexOf("work"));
expect(stageIds.indexOf("work")).toBeLessThan(stageIds.indexOf("debug")); 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", () => { 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", artifactGlob: "docs/debug/**/*.md",
icon: "Bug", icon: "Bug",
label: "Debug", label: "Debug",
participatesInPipeline: false,
}); });
expect((LucideIcons as unknown as Record<string, unknown>)[stage!.icon]).toBeTruthy(); expect((LucideIcons as unknown as Record<string, unknown>)[stage!.icon]).toBeTruthy();
}); });

View File

@@ -1,9 +1,10 @@
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CreateInteractiveAiSessionFactory } from "@fusion/core"; import type { CreateInteractiveAiSessionFactory } from "@fusion/core";
import { CeOrchestrator, warnIfStageSkillMissing } from "../session/orchestrator.js"; import { CeOrchestrator, warnIfStageSkillMissing } from "../session/orchestrator.js";
import { getCeSessionStore } from "../session/session-store.js";
import { listStages } from "../session/stage-registry.js"; import { listStages } from "../session/stage-registry.js";
import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.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 }); 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<CreateInteractiveAiSessionFactory>[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 });
}
});
}); });

View File

@@ -183,6 +183,23 @@ describe("U8 reconciler (convergence + outbound)", () => {
expect(state.currentStage).toBe("work"); 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 () => { it("outbound: advancing the pipeline propagates a NEW next-stage board task", async () => {
const { cePipelineId, task } = await landPipeline("plan"); const { cePipelineId, task } = await landPipeline("plan");
const before = (await taskStore.listTasks()).length; const before = (await taskStore.listTasks()).length;

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as realFs from "node:fs"; import * as realFs from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { registerStage } from "../../session/stage-registry.js";
// Mock node:fs so we can observe/inject behaviour around readFileSync and // Mock node:fs so we can observe/inject behaviour around readFileSync and
// accessSync without relying on vi.spyOn (ESM namespace exports are not // accessSync without relying on vi.spyOn (ESM namespace exports are not
@@ -44,35 +45,39 @@ describe("discoverArtifacts", () => {
vi.restoreAllMocks(); 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(); root = makeRepo();
writeFileSync(join(root, "STRATEGY.md"), "# Strategy"); writeFileSync(join(root, "STRATEGY.md"), "# Strategy");
writeFileSync(join(root, "CONCEPTS.md"), "# Concepts"); writeFileSync(join(root, "CONCEPTS.md"), "# Concepts");
mkdirSync(join(root, "docs/ideation"), { recursive: true }); mkdirSync(join(root, "docs/ideation"), { recursive: true });
writeFileSync(join(root, "docs/ideation/a.md"), "ideation a"); writeFileSync(join(root, "docs/ideation/a.md"), "ideation a");
writeFileSync(join(root, "docs/ideation/b.md"), "ideation b"); 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 }); mkdirSync(join(root, "docs/plans"), { recursive: true });
writeFileSync(join(root, "docs/plans/plan1.md"), "plan 1"); 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 }); mkdirSync(join(root, "docs/solutions"), { recursive: true });
writeFileSync(join(root, "docs/solutions/sol.md"), "solution"); writeFileSync(join(root, "docs/solutions/sol.md"), "solution");
const result = discoverArtifacts(root); const result = discoverArtifacts(root);
const byStage = Object.fromEntries(result.groups.map((g) => [g.stage, g])); 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(result.totalErrors).toBe(0);
expect(byStage.strategy.entries).toHaveLength(1); expect(byStage.strategy.entries).toHaveLength(1);
expect(byStage.concepts.entries).toHaveLength(1); expect(byStage.concepts.entries).toHaveLength(1);
expect(byStage.ideation.entries).toHaveLength(2); expect(byStage.ideate.entries).toHaveLength(2);
expect(byStage.brainstorm.entries).toHaveLength(1);
expect(byStage.brainstorm.entries[0]).toMatchObject({ path: "docs/brainstorms/x.md" });
expect(byStage.plan.entries).toHaveLength(1); expect(byStage.plan.entries).toHaveLength(1);
expect(byStage.plan.entries[0]).toMatchObject({ path: "docs/plans/plan1.md" }); 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); expect(byStage.solution.entries).toHaveLength(1);
// Every group present is flagged present. // 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. // All entries are artifacts in the happy path.
expect(result.groups.flatMap((g) => g.entries).every((e) => e.kind === "artifact")).toBe(true); 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(populated.map((g) => g.stage).sort()).toEqual(["plan", "strategy"]);
expect(empty.length).toBeGreaterThan(0); expect(empty.length).toBeGreaterThan(0);
// Empty groups are still present in the result so the hub can render them. // 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)", () => { 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); 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(); 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 }); mkdirSync(join(root, "docs/plans"), { recursive: true });
writeFileSync( writeFileSync(
join(root, "docs/plans/requirements.md"), join(root, "docs/plans/requirements.md"),
@@ -133,18 +136,10 @@ describe("discoverArtifacts", () => {
); );
const result = discoverArtifacts(root); const result = discoverArtifacts(root);
const brainstorm = result.groups.find((g) => g.stage === "brainstorm")!;
const plan = result.groups.find((g) => g.stage === "plan")!; 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 requirementsOnly = plan.entries.find((e) => e.name === "requirements.md");
const implementationReady = plan.entries.find((e) => e.name === "implementation.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({ expect(requirementsOnly).toMatchObject({
kind: "artifact", kind: "artifact",
artifactContract: "ce-unified-plan/v1", artifactContract: "ce-unified-plan/v1",
@@ -278,6 +273,26 @@ describe("discoverArtifacts", () => {
expect(readArtifactById(root, "plan:../../secrets.md")).toBeUndefined(); 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", () => { describe("readArtifactById", () => {

View File

@@ -10,6 +10,7 @@ import {
realpathSync, realpathSync,
} from "node:fs"; } from "node:fs";
import { isAbsolute, join, relative, sep } from "node:path"; import { isAbsolute, join, relative, sep } from "node:path";
import { listStages } from "../session/stage-registry.js";
/** /**
* CE artifact discovery (U3). * 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 * 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. * `error` entry rather than crashing the scan or being silently dropped.
* *
* Locations (per the plan): STRATEGY.md, docs/ideation/, docs/brainstorms/, * Stage locations come from the registry. Solutions and Concepts remain
* docs/plans/, docs/solutions/, CONCEPTS.md. * explicit knowledge collections because they are not interactive stages.
*/ */
export type CeArtifactStage = export type CeArtifactStage =
| "strategy" | "strategy"
| "ideation" | "ideate"
| "brainstorm"
| "plan" | "plan"
| "work"
| "debug"
| "solution" | "solution"
| "concepts"; | "concepts"
| (string & {});
/** Whether a conventional location is a single file or a directory of files. */ /** Whether a conventional location is a single file or a directory of files. */
type LocationKind = "file" | "directory"; type LocationKind = "file" | "directory";
@@ -50,14 +53,31 @@ interface ConventionalLocation {
* scanner reads ONLY these paths (and, for directories, their immediate `.md` * scanner reads ONLY these paths (and, for directories, their immediate `.md`
* children). Nothing outside this list is opened. * children). Nothing outside this list is opened.
*/ */
export const CONVENTIONAL_LOCATIONS: readonly ConventionalLocation[] = [ /*
{ stage: "strategy", label: "Strategy", path: "STRATEGY.md", kind: "file" }, FNXC:CompoundEngineeringArtifacts 2026-07-10-12:00:
{ stage: "ideation", label: "Ideation", path: "docs/ideation", kind: "directory" }, 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.
{ stage: "brainstorm", label: "Brainstorms", path: "docs/brainstorms", kind: "directory" }, */
{ stage: "plan", label: "Plans", path: "docs/plans", kind: "directory" }, function stageLocations(): ConventionalLocation[] {
{ stage: "solution", label: "Solutions", path: "docs/solutions", kind: "directory" }, return listStages().filter((definition) => definition.stageId !== "brainstorm").map((definition) => {
{ stage: "concepts", label: "Concepts", path: "CONCEPTS.md", kind: "file" }, 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. */ /** A discovered, readable artifact. */
export interface CeArtifact { export interface CeArtifact {
@@ -376,7 +396,7 @@ function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGr
*/ */
export function discoverArtifacts(projectRoot: string): DiscoveryResult { export function discoverArtifacts(projectRoot: string): DiscoveryResult {
const root = projectRoot; const root = projectRoot;
const groups = CONVENTIONAL_LOCATIONS.map((loc) => discoverLocation(root, loc)); const groups = conventionalLocations().map((loc) => discoverLocation(root, loc));
let totalArtifacts = 0; let totalArtifacts = 0;
let totalErrors = 0; let totalErrors = 0;
for (const g of groups) { for (const g of groups) {
@@ -402,7 +422,7 @@ export function readArtifactById(
if (sepIdx <= 0) return undefined; if (sepIdx <= 0) return undefined;
const stage = id.slice(0, sepIdx) as CeArtifactStage; const stage = id.slice(0, sepIdx) as CeArtifactStage;
const relPath = id.slice(sepIdx + 1); 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; if (!loc) return undefined;
const locationAbs = join(projectRoot, loc.path); const locationAbs = join(projectRoot, loc.path);

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import type { PlanningQuestion } from "@fusion/core"; import type { PlanningQuestion } from "@fusion/core";
import type { CeActivityTurn, CeConversationTurn, CeSession } from "../session/session-store.js"; import type { CeActivityTurn, CeConversationTurn, CeSession } from "../session/session-store.js";
import { getStage } from "../session/stage-registry.js";
import { canRenderRichly } from "./ce-question-support.js"; import { canRenderRichly } from "./ce-question-support.js";
/** /**
@@ -38,6 +39,27 @@ export interface CeFlowProps {
onCancel?: () => void; onCancel?: () => void;
/** Back to the launcher. */ /** Back to the launcher. */
onClose?: () => void; 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 ─────────────────────────────────────────────────────── // ── Transcript parsing ───────────────────────────────────────────────────────
@@ -270,6 +292,7 @@ function RichQuestion({
onAnswer: (questionId: string, response: unknown) => void; onAnswer: (questionId: string, response: unknown) => void;
}) { }) {
const [text, setText] = useState(""); const [text, setText] = useState("");
const [single, setSingle] = useState<string | null>(null);
const [multi, setMulti] = useState<string[]>([]); const [multi, setMulti] = useState<string[]>([]);
const submit = (response: unknown) => onAnswer(question.id, response); const submit = (response: unknown) => onAnswer(question.id, response);
@@ -326,22 +349,36 @@ function RichQuestion({
) : null} ) : null}
{question.type === "single_select" ? ( {question.type === "single_select" ? (
<ul className="ce-flow-options" data-testid="ce-flow-single"> <div className="ce-flow-choice-group" data-testid="ce-flow-single">
{(question.options ?? []).map((opt) => ( <ul className="ce-flow-options" aria-label={question.question}>
<li key={opt.id}> {(question.options ?? []).map((opt) => (
<button <li key={opt.id}>
type="button" <button
className="ce-flow-option btn" type="button"
data-option={opt.id} className={`ce-flow-option btn${single === opt.id ? " is-selected" : ""}`}
disabled={disabled} data-option={opt.id}
onClick={() => submit(opt.id)} disabled={disabled}
> aria-pressed={single === opt.id}
<span className="ce-flow-option-label">{opt.label}</span> onClick={() => setSingle(opt.id)}
{opt.description ? <span className="ce-flow-option-desc">{opt.description}</span> : null} >
</button> <span className="ce-flow-option-label">{opt.label}</span>
</li> {opt.description ? <span className="ce-flow-option-desc">{opt.description}</span> : null}
))} </button>
</ul> </li>
))}
</ul>
<button
type="button"
className="btn btn-primary ce-flow-choice-submit"
data-testid="ce-flow-single-submit"
disabled={disabled || single === null}
onClick={() => {
if (single !== null) submit(single);
}}
>
Confirm choice
</button>
</div>
) : null} ) : null}
{question.type === "multi_select" ? ( {question.type === "multi_select" ? (
@@ -350,7 +387,7 @@ function RichQuestion({
data-testid="ce-flow-multi" data-testid="ce-flow-multi"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
submit(multi); if (multi.length > 0) submit(multi);
}} }}
> >
<ul> <ul>
@@ -377,7 +414,12 @@ function RichQuestion({
); );
})} })}
</ul> </ul>
<button type="submit" className="btn btn-primary" data-testid="ce-flow-multi-submit" disabled={disabled}> <button
type="submit"
className="btn btn-primary ce-flow-choice-submit"
data-testid="ce-flow-multi-submit"
disabled={disabled || multi.length === 0}
>
Confirm selection Confirm selection
</button> </button>
</form> </form>
@@ -483,9 +525,10 @@ function QuestionPanel({
<DegradedQuestion question={question} disabled={disabled} onAnswer={onAnswer} /> <DegradedQuestion question={question} disabled={disabled} onAnswer={onAnswer} />
)} )}
{showGuidance ? ( {showGuidance ? (
<div className="ce-flow-guidance" data-testid="ce-flow-guidance"> <details className="ce-flow-guidance ce-flow-progressive-disclosure" data-testid="ce-flow-guidance">
<summary className="ce-flow-guidance-summary">Add guidance (optional)</summary>
<label className="ce-flow-guidance-label" htmlFor="ce-flow-guidance-input"> <label className="ce-flow-guidance-label" htmlFor="ce-flow-guidance-input">
Steer in your own words (optional — attached to your answer, or sent on its own) Attach context to your answer, or send guidance on its own
</label> </label>
<div className="ce-flow-guidance-row"> <div className="ce-flow-guidance-row">
<textarea <textarea
@@ -508,7 +551,7 @@ function QuestionPanel({
Send guidance Send guidance
</button> </button>
</div> </div>
</div> </details>
) : null} ) : null}
</div> </div>
); );
@@ -517,7 +560,18 @@ function QuestionPanel({
// ── Flow surface ───────────────────────────────────────────────────────────── // ── Flow surface ─────────────────────────────────────────────────────────────
export function CeFlow(props: CeFlowProps) { export function CeFlow(props: CeFlowProps) {
const { session, busy, error, onAnswer, onResume, onCancel, onClose } = props; const {
session,
busy,
error,
onAnswer,
onResume,
onCancel,
onClose,
onOpenArtifact,
nextStageId,
onStartNextStage,
} = props;
const question = session?.currentQuestion ?? undefined; const question = session?.currentQuestion ?? undefined;
@@ -539,13 +593,15 @@ export function CeFlow(props: CeFlowProps) {
const recoverable = status === "interrupted" || status === "error"; const recoverable = status === "interrupted" || status === "error";
const cancellable = status === "launching" || status === "active" || status === "awaiting_input"; const cancellable = status === "launching" || status === "active" || status === "awaiting_input";
const working = status === "active" || status === "launching"; const working = status === "active" || status === "launching";
const currentStageLabel = stageLabel(session.stage);
const automaticNextStageId = nextStageId && nextStageId !== "debug" && session.stage !== "debug" ? nextStageId : undefined;
return ( return (
<div className="ce-flow card" data-testid="ce-flow" data-status={status} data-stage={session.stage}> <div className="ce-flow card" data-testid="ce-flow" data-status={status} data-stage={session.stage}>
<header className="ce-flow-header"> <header className="ce-flow-header">
<h3>{session.stage}</h3> <h3>{currentStageLabel}</h3>
<span className="ce-flow-status" data-testid="ce-flow-status"> <span className="ce-flow-status" data-testid="ce-flow-status" role="status" aria-live="polite" aria-atomic="true">
{status.replace("_", " ")} {humanizeIdentifier(status)}
</span> </span>
{onCancel && cancellable ? ( {onCancel && cancellable ? (
<button <button
@@ -570,7 +626,7 @@ export function CeFlow(props: CeFlowProps) {
<Transcript history={session.conversationHistory} /> <Transcript history={session.conversationHistory} />
{working || (busy && status !== "awaiting_input") ? ( {working || (busy && status !== "awaiting_input") ? (
<div className="ce-flow-working" data-testid="ce-flow-thinking"> <div className="ce-flow-working" data-testid="ce-flow-thinking" role="status" aria-live="polite" aria-atomic="false">
<p className="ce-flow-working-label"> <p className="ce-flow-working-label">
<span className="ce-flow-pulse" aria-hidden="true" /> <span className="ce-flow-pulse" aria-hidden="true" />
Agent working… Agent working…
@@ -588,11 +644,11 @@ export function CeFlow(props: CeFlowProps) {
) : null} ) : null}
{status === "awaiting_input" && question ? ( {status === "awaiting_input" && question ? (
<QuestionPanel question={question} disabled={Boolean(busy)} onAnswer={onAnswer} /> <QuestionPanel key={question.id} question={question} disabled={Boolean(busy)} onAnswer={onAnswer} />
) : null} ) : null}
{recoverable ? ( {recoverable ? (
<div className="ce-flow-recover" data-testid="ce-flow-recover"> <div className="ce-flow-recover ce-flow-action-state" data-testid="ce-flow-recover">
<p className="ce-flow-error" role="alert"> <p className="ce-flow-error" role="alert">
Session {status}{session.error ? `: ${session.error}` : ""}. Session {status}{session.error ? `: ${session.error}` : ""}.
</p> </p>
@@ -605,13 +661,29 @@ export function CeFlow(props: CeFlowProps) {
) : null} ) : null}
{settledTerminal ? ( {settledTerminal ? (
<div className="ce-flow-complete" data-testid="ce-flow-complete"> <div className="ce-flow-complete ce-flow-action-state" data-testid="ce-flow-complete" role="status" aria-live="polite">
<p>Stage complete.</p> <p>{currentStageLabel} complete.</p>
{session.artifactPath ? ( {session.artifactPath ? (
<p className="ce-flow-artifact-path" data-testid="ce-flow-artifact-path"> <p className="ce-flow-artifact-path" data-testid="ce-flow-artifact-path">
Artifact: {session.artifactPath} Artifact: {session.artifactPath}
</p> </p>
) : null} ) : null}
<div className="ce-flow-complete-actions">
{session.artifactPath && onOpenArtifact ? (
<button type="button" className="btn" onClick={() => onOpenArtifact(session.artifactPath!)}>
Open artifact
</button>
) : null}
{automaticNextStageId && onStartNextStage ? (
<button
type="button"
className="btn btn-primary"
onClick={() => onStartNextStage(automaticNextStageId)}
>
Start {stageLabel(automaticNextStageId)}
</button>
) : null}
</div>
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -719,3 +719,67 @@ The shared ViewHeader sits directly above plugin content, so the body needs top
.ce-flow-complete p { .ce-flow-complete p {
margin: 0; margin: 0;
} }
/*
FNXC:CompoundEngineeringUI 2026-07-10-12:10:
The pipeline overview must communicate progression and the next useful action at a glance on desktop and mobile. Use a connected, restrained stage rail, a single high-signal resume row, and progressive disclosure for session management; avoid nested cards and oversized dashboard typography.
*/
.ce-pipeline { display: flex; flex-direction: column; gap: var(--space-md); }
.ce-pipeline-header { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-md); }
.ce-pipeline-header h2 { margin: 0; font-size: 1rem; font-weight: 650; }
.ce-pipeline-header p { margin: calc(var(--space-xs) / 2) 0 0; color: var(--text-muted); font-size: 0.78rem; }
.ce-investigate { display: inline-flex; align-items: center; gap: var(--space-xs); flex: none; }
.ce-stage-rail { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); list-style: none; margin: 0; padding: 0; }
.ce-stage-step { position: relative; min-width: 0; border-block: 1px solid var(--border); border-left: 1px solid var(--border); background: var(--surface); }
.ce-stage-step:first-child { border-radius: var(--radius-md) 0 0 var(--radius-md); }
.ce-stage-step:last-child { border-right: 1px solid var(--border); border-radius: 0 var(--radius-md) var(--radius-md) 0; }
.ce-stage-step::after { position: absolute; z-index: 2; top: calc(var(--space-lg) + 1px); right: calc(var(--space-xs) * -1); width: calc(var(--space-sm) - 2px); height: calc(var(--space-sm) - 2px); border-top: 1px solid var(--border); border-right: 1px solid var(--border); background: inherit; content: ""; transform: rotate(45deg); }
.ce-stage-step:last-child::after { display: none; }
.ce-stage-main { display: flex; width: 100%; min-height: calc(var(--space-2xl) * 2.25); align-items: flex-start; gap: var(--space-sm); border: 0; background: transparent; color: inherit; cursor: pointer; padding: var(--space-md); text-align: left; }
.ce-stage-main:hover:not(:disabled), .ce-stage-main:focus-visible { background: var(--card-hover); }
.ce-stage-main:focus-visible { position: relative; z-index: 3; outline: 2px solid var(--color-info); outline-offset: -2px; }
.ce-stage-icon { display: grid; width: calc(var(--space-lg) + var(--space-xs)); height: calc(var(--space-lg) + var(--space-xs)); flex: none; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--text-muted); }
.ce-stage-step[data-status="complete"] .ce-stage-icon { border-color: color-mix(in srgb, var(--color-success) 45%, var(--border)); color: var(--color-success); }
.ce-stage-step[data-status="active"] .ce-stage-icon, .ce-stage-step[data-status="needs-input"] .ce-stage-icon { border-color: var(--color-info); background: color-mix(in srgb, var(--color-info) 12%, var(--surface)); color: var(--color-info); }
.ce-stage-step[data-status="needs-input"] .ce-stage-status { color: var(--color-warning); }
.ce-stage-copy { display: flex; min-width: 0; flex-direction: column; gap: calc(var(--space-xs) / 2); }
.ce-stage-copy strong { overflow: hidden; font-size: 0.86rem; text-overflow: ellipsis; white-space: nowrap; }
.ce-stage-status { color: var(--text-muted); font-size: 0.7rem; }
.ce-stage-artifact { display: flex; width: calc(100% - var(--space-lg)); min-width: 0; align-items: center; gap: var(--space-xs); margin: 0 var(--space-sm) var(--space-sm); border: 0; background: transparent; color: var(--text-dim); cursor: pointer; font: inherit; font-size: 0.68rem; text-align: left; }
.ce-stage-artifact span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ce-stage-artifact.is-empty { cursor: default; }
.ce-resume-banner { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-sm); width: 100%; border: 1px solid color-mix(in srgb, var(--color-info) 35%, var(--border)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-info) 6%, var(--surface)); color: inherit; cursor: pointer; padding: var(--space-sm) var(--space-md); text-align: left; }
.ce-resume-banner:hover, .ce-resume-banner:focus-visible { border-color: var(--color-info); }
.ce-resume-banner > span:not(.ce-resume-icon) { display: flex; min-width: 0; flex-direction: column; gap: calc(var(--space-xs) / 2); }
.ce-resume-banner strong { font-size: 0.84rem; }
.ce-resume-banner small { overflow: hidden; color: var(--text-muted); font-size: 0.72rem; text-overflow: ellipsis; white-space: nowrap; }
.ce-resume-icon { display: grid; width: calc(var(--space-lg) + var(--space-xs)); height: calc(var(--space-lg) + var(--space-xs)); place-items: center; border-radius: 50%; background: var(--color-info); color: var(--surface); }
.ce-readiness { width: max-content; margin-top: calc(var(--space-xs) / 2); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-muted); font-size: 0.65rem; padding: 1px var(--space-xs); }
.ce-readiness-implementation-ready { border-color: color-mix(in srgb, var(--color-success) 40%, var(--border)); color: var(--color-success); }
.ce-flow-choice-group { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-md); }
.ce-flow-option.is-selected { border-color: var(--color-info); background: color-mix(in srgb, var(--color-info) 9%, var(--surface)); }
.ce-flow-choice-submit { min-width: calc(var(--space-2xl) * 3); }
.ce-flow-progressive-disclosure { border-top: 1px solid var(--border); padding-top: var(--space-sm); }
.ce-flow-guidance-summary { width: max-content; color: var(--text-muted); cursor: pointer; font-size: 0.76rem; font-weight: 600; }
.ce-flow-progressive-disclosure[open] .ce-flow-guidance-summary { margin-bottom: var(--space-sm); color: var(--text); }
.ce-flow-action-state { display: flex; min-height: calc(var(--space-2xl) * 3); flex: 1 1 auto; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-md); text-align: center; }
.ce-flow-action-state > p { max-width: 34rem; }
.ce-flow-complete-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-sm); }
.ce-flow[data-status="awaiting_input"] .ce-flow-question-panel { border-color: color-mix(in srgb, var(--color-info) 40%, var(--border)); }
.ce-flow[data-status="active"] .ce-flow-status, .ce-flow[data-status="launching"] .ce-flow-status { color: var(--color-info); }
.ce-sessions { border-top: 1px solid var(--border); padding-top: var(--space-sm); }
.ce-sessions-summary { display: flex; align-items: center; justify-content: space-between; color: var(--text-muted); cursor: pointer; font-size: 0.8rem; font-weight: 600; padding: var(--space-xs) 0; }
.ce-sessions[open] .ce-sessions-summary { margin-bottom: var(--space-sm); }
.ce-discard-confirm { display: flex; align-items: center; gap: var(--space-xs); margin-left: auto; }
@media (max-width: 768px) {
.ce-pipeline-header { align-items: center; }
.ce-stage-rail { grid-template-columns: 1fr; }
.ce-stage-step, .ce-stage-step:first-child, .ce-stage-step:last-child { border: 1px solid var(--border); border-bottom: 0; border-radius: 0; }
.ce-stage-step:first-child { border-radius: var(--radius-md) var(--radius-md) 0 0; }
.ce-stage-step:last-child { border-bottom: 1px solid var(--border); border-radius: 0 0 var(--radius-md) var(--radius-md); }
.ce-stage-step::after { display: none; }
.ce-stage-main { min-height: auto; align-items: center; padding: var(--space-sm) var(--space-md); }
.ce-stage-artifact { margin-left: calc(var(--space-2xl) + var(--space-xs)); }
.ce-discard-confirm { flex-wrap: wrap; justify-content: flex-end; }
}

View File

@@ -1,5 +1,5 @@
import "./CompoundEngineeringView.css"; import "./CompoundEngineeringView.css";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import * as LucideIcons from "lucide-react"; import * as LucideIcons from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types"; import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
@@ -9,7 +9,7 @@ import { useViewportMode } from "./hooks/useViewportMode.js";
import { useCeSession, type CeSessionSubscribe } from "./hooks/useCeSession.js"; import { useCeSession, type CeSessionSubscribe } from "./hooks/useCeSession.js";
import { useCeSessions, type CeSessionsSubscribe } from "./hooks/useCeSessions.js"; import { useCeSessions, type CeSessionsSubscribe } from "./hooks/useCeSessions.js";
import { CeFlow } from "./CeFlow.js"; import { CeFlow } from "./CeFlow.js";
import { getStage, listStages, type CeStageDefinition } from "../session/stage-registry.js"; import { getStage, listPipelineStages, listStages, type CeStageDefinition } from "../session/stage-registry.js";
import type { CeArtifactEntry, CeArtifactGroup } from "../artifacts/discovery.js"; import type { CeArtifactEntry, CeArtifactGroup } from "../artifacts/discovery.js";
import type { CeSession, CeSessionStatus } from "../session/session-store.js"; import type { CeSession, CeSessionStatus } from "../session/session-store.js";
@@ -94,13 +94,14 @@ function SessionsPanel({
onCancel: (session: CeSession) => void; onCancel: (session: CeSession) => void;
onDiscard: (session: CeSession) => void; onDiscard: (session: CeSession) => void;
}) { }) {
const [discardCandidate, setDiscardCandidate] = useState<string>();
if (sessions.length === 0) return null; if (sessions.length === 0) return null;
return ( return (
<section className="ce-sessions card" data-testid="ce-sessions"> <details className="ce-sessions" data-testid="ce-sessions">
<header className="ce-group-header"> <summary className="ce-sessions-summary">
<h3>Sessions</h3> <span>Session history</span>
<span className="ce-group-count">{sessions.length}</span> <span className="ce-group-count">{sessions.length}</span>
</header> </summary>
<ul className="ce-sessions-list"> <ul className="ce-sessions-list">
{sessions.map((s) => { {sessions.map((s) => {
const stageLabel = getStage(s.stage)?.label ?? s.stage; const stageLabel = getStage(s.stage)?.label ?? s.stage;
@@ -127,15 +128,26 @@ function SessionsPanel({
<span className="ce-session-updated">{new Date(s.updatedAt).toLocaleString()}</span> <span className="ce-session-updated">{new Date(s.updatedAt).toLocaleString()}</span>
</button> </button>
{TERMINAL.has(s.status) ? ( {TERMINAL.has(s.status) ? (
<button discardCandidate === s.id ? (
type="button" <span className="ce-discard-confirm" data-testid="ce-discard-confirm">
className="btn ce-session-discard" <button type="button" className="btn ce-session-discard" disabled={disabled} onClick={() => onDiscard(s)}>
data-testid="ce-session-discard" Delete permanently
disabled={disabled} </button>
onClick={() => onDiscard(s)} <button type="button" className="btn" disabled={disabled} onClick={() => setDiscardCandidate(undefined)}>
> Keep
Discard </button>
</button> </span>
) : (
<button
type="button"
className="btn ce-session-discard"
data-testid="ce-session-discard"
disabled={disabled}
onClick={() => setDiscardCandidate(s.id)}
>
Discard
</button>
)
) : ( ) : (
<button <button
type="button" type="button"
@@ -146,13 +158,118 @@ function SessionsPanel({
aria-label="Cancel session" aria-label="Cancel session"
title="Cancel session" title="Cancel session"
> >
<LucideIcons.Trash2 size={16} aria-hidden="true" /> <LucideIcons.CircleStop size={16} aria-hidden="true" />
</button> </button>
)} )}
</li> </li>
); );
})} })}
</ul> </ul>
</details>
);
}
function nextPipelineStageId(stageId: string): string | undefined {
const stages = listPipelineStages();
const index = stages.findIndex((stage) => stage.stageId === stageId);
return index >= 0 ? stages[index + 1]?.stageId : undefined;
}
type RailStatus = "Not started" | "Active" | "Needs input" | "Complete";
function artifactForStage(stageId: string, groups: CeArtifactGroup[]): CeArtifactEntry | undefined {
const groupId = stageId === "brainstorm" ? "plan" : stageId;
const entries = groups.find((group) => group.stage === groupId)?.entries ?? [];
if (stageId === "brainstorm") {
return entries.find((entry) => (
entry.kind === "artifact"
&& entry.artifactContract === "ce-unified-plan/v1"
&& (entry.artifactReadiness === "requirements-only" || entry.artifactReadiness === "implementation-ready")
));
}
if (stageId === "plan") {
return entries.find((entry) => (
entry.kind === "artifact"
&& (entry.artifactReadiness === "implementation-ready" || !entry.artifactReadiness)
));
}
return entries.find((entry) => entry.kind === "artifact");
}
function railStatus(stageId: string, sessions: CeSession[], artifact?: CeArtifactEntry): RailStatus {
const latest = sessions.find((session) => session.stage === stageId);
if (latest?.status === "awaiting_input") return "Needs input";
if (latest?.status === "active" || latest?.status === "launching") return "Active";
if (latest?.status === "completed" || artifact?.kind === "artifact") return "Complete";
return "Not started";
}
/**
* FNXC:CompoundEngineeringUI 2026-07-10-12:00:
* Operators need the five-stage compounding pipeline as the overview's primary navigation. Each stage shows actionable state and its latest durable artifact, while Debug remains a separate investigation action because it is not pipeline progression.
*/
function PipelineOverview({
groups,
sessions,
disabled,
openFile,
onLaunch,
onOpenSession,
}: {
groups: CeArtifactGroup[];
sessions: CeSession[];
disabled: boolean;
openFile?: PluginDashboardViewContext["openFile"];
onLaunch: (stage: CeStageDefinition) => void;
onOpenSession: (session: CeSession) => void;
}) {
const stages = listPipelineStages();
const debug = getStage("debug");
return (
<section className="ce-pipeline" data-testid="ce-pipeline">
<header className="ce-pipeline-header">
<div>
<h2>Pipeline</h2>
<p>Move from direction to delivered work.</p>
</div>
{debug ? (
<button type="button" className="btn ce-investigate" data-testid="ce-investigate" disabled={disabled} onClick={() => onLaunch(debug)}>
<LucideIcons.Bug size={16} aria-hidden="true" /> Investigate
</button>
) : null}
</header>
<ol className="ce-stage-rail">
{stages.map((stage) => {
const artifact = artifactForStage(stage.stageId, groups);
const latestSession = sessions.find((session) => session.stage === stage.stageId);
const status = railStatus(stage.stageId, sessions, artifact);
const Icon = resolveIcon(stage.icon);
return (
<li key={stage.stageId} className="ce-stage-step" data-status={status.toLowerCase().replace(" ", "-")}>
<button
type="button"
className="ce-stage-main"
data-testid="ce-pipeline-stage"
data-stage={stage.stageId}
disabled={disabled}
onClick={() => latestSession && !TERMINAL.has(latestSession.status) ? onOpenSession(latestSession) : onLaunch(stage)}
>
<span className="ce-stage-icon"><Icon size={17} aria-hidden="true" /></span>
<span className="ce-stage-copy">
<strong>{stage.label}</strong>
<span className="ce-stage-status">{status}</span>
</span>
</button>
{artifact?.kind === "artifact" ? (
<button type="button" className="ce-stage-artifact" onClick={() => openFile?.(artifact.path, { workspace: "project" })}>
<LucideIcons.FileText size={13} aria-hidden="true" />
<span>{artifact.name}</span>
</button>
) : <span className="ce-stage-artifact is-empty">No artifact yet</span>}
</li>
);
})}
</ol>
</section> </section>
); );
} }
@@ -213,10 +330,20 @@ function ArtifactRow({
</li> </li>
); );
} }
const readinessLabel = entry.artifactReadiness === "requirements-only"
? "Requirements"
: entry.artifactReadiness === "implementation-ready"
? "Implementation ready"
: entry.artifactReadiness;
return ( return (
<li className={`ce-artifact${selected ? " is-selected" : ""}`} data-testid="ce-artifact"> <li className={`ce-artifact${selected ? " is-selected" : ""}`} data-testid="ce-artifact">
<button type="button" className="ce-artifact-btn" onClick={() => onSelect(entry.id)}> <button type="button" className="ce-artifact-btn" onClick={() => onSelect(entry.id)}>
<span className="ce-artifact-name">{entry.name}</span> <span className="ce-artifact-name">{entry.name}</span>
{entry.artifactReadiness ? (
<span className={`ce-readiness ce-readiness-${entry.artifactReadiness}`}>
{readinessLabel}
</span>
) : null}
<span className="ce-artifact-path">{entry.path}</span> <span className="ce-artifact-path">{entry.path}</span>
</button> </button>
<button <button
@@ -295,6 +422,8 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
}); });
}, [subscribePluginEvents]); }, [subscribePluginEvents]);
const ceSession = useCeSession(subscribe ? { subscribe } : {}); const ceSession = useCeSession(subscribe ? { subscribe } : {});
const activeSession = ceSession.session;
const openSession = ceSession.open;
// Session list refresh: ANY CE push event means some session changed. // Session list refresh: ANY CE push event means some session changed.
const subscribeList = useMemo<CeSessionsSubscribe | undefined>(() => { const subscribeList = useMemo<CeSessionsSubscribe | undefined>(() => {
if (!subscribePluginEvents) return undefined; if (!subscribePluginEvents) return undefined;
@@ -308,6 +437,14 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
const [launcherOpen, setLauncherOpen] = useState(false); const [launcherOpen, setLauncherOpen] = useState(false);
const [sessionActionBusy, setSessionActionBusy] = useState(false); const [sessionActionBusy, setSessionActionBusy] = useState(false);
const setSessionUrl = useCallback((value?: string) => {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
if (value) url.searchParams.set("ceSession", value);
else url.searchParams.delete("ceSession");
window.history.replaceState(window.history.state, "", url);
}, []);
const totalArtifacts = result?.totalArtifacts ?? 0; const totalArtifacts = result?.totalArtifacts ?? 0;
const totalErrors = result?.totalErrors ?? 0; const totalErrors = result?.totalErrors ?? 0;
const hasAnything = totalArtifacts > 0 || totalErrors > 0; const hasAnything = totalArtifacts > 0 || totalErrors > 0;
@@ -319,20 +456,24 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
const onStart = () => setLauncherOpen(true); const onStart = () => setLauncherOpen(true);
const onLaunch = useCallback( const onLaunch = useCallback(
(stage: CeStageDefinition) => { (stage: CeStageDefinition, sourceSessionId?: string) => {
setLauncherOpen(false); setLauncherOpen(false);
void ceSession void ceSession
.start(stage.stageId, { message: `Start the ${stage.label} stage.`, projectId }) .start(stage.stageId, { message: `Start the ${stage.label} stage.`, projectId, sourceSessionId })
.then(() => ceSessions.refresh()); .then((session) => {
if (session) setSessionUrl(session.id);
return ceSessions.refresh();
});
}, },
[ceSession, ceSessions, projectId], [ceSession, ceSessions, projectId, setSessionUrl],
); );
const onOpenSession = useCallback( const onOpenSession = useCallback(
(s: CeSession) => { (s: CeSession) => {
void ceSession.open(s.id, { projectId }); setSessionUrl(s.id);
void openSession(s.id, { projectId });
}, },
[ceSession, projectId], [openSession, projectId, setSessionUrl],
); );
const onCancelSession = useCallback( const onCancelSession = useCallback(
@@ -341,11 +482,14 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
void ceSessions void ceSessions
.cancel(s.id) .cancel(s.id)
.then(() => { .then(() => {
if (ceSession.session?.id === s.id) ceSession.reset(); if (ceSession.session?.id === s.id) {
ceSession.reset();
setSessionUrl();
}
}) })
.finally(() => setSessionActionBusy(false)); .finally(() => setSessionActionBusy(false));
}, },
[ceSession, ceSessions], [ceSession, ceSessions, setSessionUrl],
); );
const onDiscardSession = useCallback( const onDiscardSession = useCallback(
@@ -360,8 +504,22 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
// it keeps running server-side and stays reachable from the sessions panel. // it keeps running server-side and stays reachable from the sessions panel.
const onCloseFlow = useCallback(() => { const onCloseFlow = useCallback(() => {
ceSession.reset(); ceSession.reset();
setSessionUrl();
void ceSessions.refresh(); void ceSessions.refresh();
}, [ceSession, ceSessions]); }, [ceSession, ceSessions, setSessionUrl]);
const resumableSession = useMemo(() => {
return ceSessions.sessions.find(
(session) => session.status === "active" || session.status === "awaiting_input" || session.status === "launching",
);
}, [ceSessions.sessions]);
useEffect(() => {
if (activeSession || ceSessions.loading || ceSessions.sessions.length === 0 || typeof window === "undefined") return;
const sessionId = new URL(window.location.href).searchParams.get("ceSession");
const session = ceSessions.sessions.find((candidate) => candidate.id === sessionId);
if (session) void openSession(session.id, { projectId });
}, [activeSession, ceSessions.loading, ceSessions.sessions, openSession, projectId]);
// Once a session is active here, the flow renderer owns the surface until // Once a session is active here, the flow renderer owns the surface until
// closed — but the sessions panel stays visible so other sessions remain // closed — but the sessions panel stays visible so other sessions remain
@@ -393,6 +551,12 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
onResume={ceSession.resume} onResume={ceSession.resume}
onCancel={() => onCancelSession(ceSession.session!)} onCancel={() => onCancelSession(ceSession.session!)}
onClose={onCloseFlow} onClose={onCloseFlow}
onOpenArtifact={openFile ? (path) => openFile(path, { workspace: "project" }) : undefined}
nextStageId={nextPipelineStageId(ceSession.session.stage)}
onStartNextStage={(stageId) => {
const stage = getStage(stageId);
if (stage) onLaunch(stage, ceSession.session!.id);
}}
/> />
</div> </div>
</div> </div>
@@ -421,8 +585,28 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
/> />
<div className="ce-view-body"> <div className="ce-view-body">
<PipelineOverview
groups={result?.groups ?? []}
sessions={ceSessions.sessions}
disabled={ceSession.busy || sessionActionBusy}
openFile={openFile}
onLaunch={onLaunch}
onOpenSession={onOpenSession}
/>
{resumableSession ? (
<button type="button" className="ce-resume-banner" data-testid="ce-resume-latest" onClick={() => onOpenSession(resumableSession)}>
<span className="ce-resume-icon"><LucideIcons.Play size={16} aria-hidden="true" /></span>
<span>
<strong>{resumableSession.status === "awaiting_input" ? "Input needed" : "Resume active session"}</strong>
<small>{getStage(resumableSession.stage)?.label ?? resumableSession.stage} · Updated {new Date(resumableSession.updatedAt).toLocaleString()}</small>
</span>
<LucideIcons.ChevronRight size={17} aria-hidden="true" />
</button>
) : null}
{launcherOpen ? ( {launcherOpen ? (
<StageLauncher stages={stages} disabled={ceSession.busy} onLaunch={onLaunch} /> <StageLauncher stages={listPipelineStages()} disabled={ceSession.busy} onLaunch={onLaunch} />
) : null} ) : null}
<SessionsPanel <SessionsPanel

View File

@@ -96,7 +96,7 @@ describe("CeFlow — rich question rendering + submit", () => {
expect(onAnswer).toHaveBeenCalledWith("q-text", "ship faster"); expect(onAnswer).toHaveBeenCalledWith("q-text", "ship faster");
}); });
it("renders + submits a single_select question", () => { it("selects a single_select answer and submits only after explicit confirmation", () => {
const onAnswer = vi.fn(); const onAnswer = vi.fn();
const q: PlanningQuestion = { const q: PlanningQuestion = {
id: "q-single", id: "q-single",
@@ -109,9 +109,35 @@ describe("CeFlow — rich question rendering + submit", () => {
}; };
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />); render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
fireEvent.click(screen.getByText("Beta")); fireEvent.click(screen.getByText("Beta"));
expect(onAnswer).not.toHaveBeenCalled();
expect(screen.getByText("Beta").closest("button")).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "Confirm choice" }));
expect(onAnswer).toHaveBeenCalledWith("q-single", "b"); expect(onAnswer).toHaveBeenCalledWith("q-single", "b");
}); });
it("resets local answer state when the session advances to another question", () => {
const onAnswer = vi.fn();
const first: PlanningQuestion = {
id: "q-first",
type: "single_select",
question: "First choice",
options: [{ id: "shared", label: "First shared option" }],
};
const second: PlanningQuestion = {
id: "q-second",
type: "single_select",
question: "Second choice",
options: [{ id: "shared", label: "Second shared option" }],
};
const { rerender } = render(<CeFlow session={makeSession({ currentQuestion: first })} onAnswer={onAnswer} />);
fireEvent.click(screen.getByText("First shared option"));
expect(screen.getByTestId("ce-flow-single-submit")).toBeEnabled();
rerender(<CeFlow session={makeSession({ currentQuestion: second })} onAnswer={onAnswer} />);
expect(screen.getByText("Second shared option").closest("button")).toHaveAttribute("aria-pressed", "false");
expect(screen.getByTestId("ce-flow-single-submit")).toBeDisabled();
});
it("renders + submits a multi_select question", () => { it("renders + submits a multi_select question", () => {
const onAnswer = vi.fn(); const onAnswer = vi.fn();
const q: PlanningQuestion = { const q: PlanningQuestion = {
@@ -132,6 +158,21 @@ describe("CeFlow — rich question rendering + submit", () => {
expect(onAnswer).toHaveBeenCalledWith("q-multi", ["g1", "g3"]); expect(onAnswer).toHaveBeenCalledWith("q-multi", ["g1", "g3"]);
}); });
it("does not submit an empty multi_select", () => {
const onAnswer = vi.fn();
const question: PlanningQuestion = {
id: "q-multi",
type: "multi_select",
question: "Which goals?",
options: [{ id: "g1", label: "Speed" }],
};
render(<CeFlow session={makeSession({ currentQuestion: question })} onAnswer={onAnswer} />);
const submit = screen.getByTestId("ce-flow-multi-submit");
expect(submit).toBeDisabled();
fireEvent.click(submit);
expect(onAnswer).not.toHaveBeenCalled();
});
it("renders + submits a confirm question (both branches)", () => { it("renders + submits a confirm question (both branches)", () => {
const onAnswer = vi.fn(); const onAnswer = vi.fn();
const q: PlanningQuestion = { id: "q-c", type: "confirm", question: "Write the doc now?" }; const q: PlanningQuestion = { id: "q-c", type: "confirm", question: "Write the doc now?" };
@@ -204,6 +245,8 @@ describe("CeFlow — steering (guidance channel)", () => {
target: { value: "focus on mobile" }, target: { value: "focus on mobile" },
}); });
fireEvent.click(screen.getByText("Beta")); fireEvent.click(screen.getByText("Beta"));
expect(onAnswer).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Confirm choice" }));
expect(onAnswer).toHaveBeenCalledWith("q-steer", { value: "b", comment: "focus on mobile" }); expect(onAnswer).toHaveBeenCalledWith("q-steer", { value: "b", comment: "focus on mobile" });
}); });
@@ -223,6 +266,7 @@ describe("CeFlow — steering (guidance channel)", () => {
const onAnswer = vi.fn(); const onAnswer = vi.fn();
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />); render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
fireEvent.click(screen.getByText("Alpha")); fireEvent.click(screen.getByText("Alpha"));
fireEvent.click(screen.getByRole("button", { name: "Confirm choice" }));
expect(onAnswer).toHaveBeenCalledWith("q-steer", "a"); expect(onAnswer).toHaveBeenCalledWith("q-steer", "a");
}); });
@@ -399,6 +443,14 @@ describe("CeFlow — Q&A transcript rendering", () => {
}); });
describe("CeFlow — lifecycle surfaces", () => { describe("CeFlow — lifecycle surfaces", () => {
it("renders the registry label and exposes async state through polite live regions", () => {
render(<CeFlow session={makeSession({ stage: "brainstorm", status: "active" })} onAnswer={vi.fn()} />);
expect(screen.getByRole("heading", { name: "Brainstorm" })).toBeInTheDocument();
expect(screen.getByTestId("ce-flow-status")).toHaveAttribute("role", "status");
expect(screen.getByTestId("ce-flow-status")).toHaveAttribute("aria-live", "polite");
expect(screen.getByTestId("ce-flow-thinking")).toHaveAttribute("role", "status");
});
it("shows the working pane while a turn runs", () => { it("shows the working pane while a turn runs", () => {
render(<CeFlow session={makeSession({ status: "active", currentQuestion: null })} busy onAnswer={vi.fn()} />); render(<CeFlow session={makeSession({ status: "active", currentQuestion: null })} busy onAnswer={vi.fn()} />);
expect(screen.getByTestId("ce-flow-thinking")).toBeInTheDocument(); expect(screen.getByTestId("ce-flow-thinking")).toBeInTheDocument();
@@ -457,4 +509,35 @@ describe("CeFlow — lifecycle surfaces", () => {
fireEvent.click(screen.getByTestId("ce-flow-resume")); fireEvent.click(screen.getByTestId("ce-flow-resume"));
expect(onResume).toHaveBeenCalled(); expect(onResume).toHaveBeenCalled();
}); });
it("offers artifact and next-stage actions after completion", () => {
const onOpenArtifact = vi.fn();
const onStartNextStage = vi.fn();
render(
<CeFlow
session={makeSession({ status: "completed", stage: "plan", artifactPath: "/repo/docs/plans/x.md" })}
onAnswer={vi.fn()}
onOpenArtifact={onOpenArtifact}
nextStageId="work"
onStartNextStage={onStartNextStage}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Open artifact" }));
expect(onOpenArtifact).toHaveBeenCalledWith("/repo/docs/plans/x.md");
fireEvent.click(screen.getByRole("button", { name: "Start Work" }));
expect(onStartNextStage).toHaveBeenCalledWith("work");
expect(screen.getByTestId("ce-flow-complete")).toHaveAttribute("role", "status");
});
it("keeps Debug outside automatic next-stage completion actions", () => {
render(
<CeFlow
session={makeSession({ status: "completed", stage: "work" })}
onAnswer={vi.fn()}
nextStageId="debug"
onStartNextStage={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: /start debug/i })).not.toBeInTheDocument();
});
}); });

View File

@@ -14,6 +14,9 @@ const cancelSession = vi.fn(async (_id: string, _projectId?: string): Promise<Ce
const getSession = vi.fn(async (_id: string, _projectId?: string): Promise<CeSession> => { const getSession = vi.fn(async (_id: string, _projectId?: string): Promise<CeSession> => {
throw new Error("getSession mock not configured"); throw new Error("getSession mock not configured");
}); });
const startSession = vi.fn(async (_stage: string, _opts?: unknown): Promise<CeSession> => {
throw new Error("startSession mock not configured");
});
vi.mock("../hooks/api.js", () => ({ vi.mock("../hooks/api.js", () => ({
listArtifacts: (projectId?: string) => listArtifacts(projectId), listArtifacts: (projectId?: string) => listArtifacts(projectId),
getArtifactPreviewUrl: (id: string) => `/preview/${id}`, getArtifactPreviewUrl: (id: string) => `/preview/${id}`,
@@ -21,7 +24,7 @@ vi.mock("../hooks/api.js", () => ({
deleteSession: (id: string, projectId?: string) => deleteSession(id, projectId), deleteSession: (id: string, projectId?: string) => deleteSession(id, projectId),
cancelSession: (id: string, projectId?: string) => cancelSession(id, projectId), cancelSession: (id: string, projectId?: string) => cancelSession(id, projectId),
getSession: (id: string, projectId?: string) => getSession(id, projectId), getSession: (id: string, projectId?: string) => getSession(id, projectId),
startSession: vi.fn(), startSession: (stage: string, opts?: unknown) => startSession(stage, opts),
answerSession: vi.fn(), answerSession: vi.fn(),
resumeSession: vi.fn(), resumeSession: vi.fn(),
})); }));
@@ -50,9 +53,10 @@ function mkCeSession(over: Partial<CeSession>): CeSession {
const ALL_STAGES: Array<{ stage: DiscoveryResult["groups"][number]["stage"]; label: string }> = [ const ALL_STAGES: Array<{ stage: DiscoveryResult["groups"][number]["stage"]; label: string }> = [
{ stage: "strategy", label: "Strategy" }, { stage: "strategy", label: "Strategy" },
{ stage: "ideation", label: "Ideation" }, { stage: "ideate", label: "Ideate" },
{ stage: "brainstorm", label: "Brainstorms" }, { stage: "plan", label: "Brainstorm / Plan" },
{ stage: "plan", label: "Plans" }, { stage: "work", label: "Work" },
{ stage: "debug", label: "Debug" },
{ stage: "solution", label: "Solutions" }, { stage: "solution", label: "Solutions" },
{ stage: "concepts", label: "Concepts" }, { stage: "concepts", label: "Concepts" },
]; ];
@@ -77,6 +81,7 @@ function makeResult(overrides: Partial<Record<DiscoveryResult["groups"][number][
describe("CompoundEngineeringView", () => { describe("CompoundEngineeringView", () => {
beforeEach(() => { beforeEach(() => {
window.history.replaceState({}, "", "/");
__test_clearArtifactsCache(); __test_clearArtifactsCache();
listArtifacts.mockReset(); listArtifacts.mockReset();
listSessions.mockReset(); listSessions.mockReset();
@@ -86,6 +91,7 @@ describe("CompoundEngineeringView", () => {
cancelSession.mockReset(); cancelSession.mockReset();
cancelSession.mockImplementation(async (id: string, projectId?: string) => mkCeSession({ id, projectId: projectId ?? null, status: "interrupted", error: "Cancelled by user" })); cancelSession.mockImplementation(async (id: string, projectId?: string) => mkCeSession({ id, projectId: projectId ?? null, status: "interrupted", error: "Cancelled by user" }));
getSession.mockReset(); getSession.mockReset();
startSession.mockReset();
}); });
afterEach(() => vi.clearAllMocks()); afterEach(() => vi.clearAllMocks());
@@ -213,6 +219,8 @@ describe("CompoundEngineeringView", () => {
expect(rows[0].querySelector("[data-testid='ce-session-cancel']")).toBeInTheDocument(); expect(rows[0].querySelector("[data-testid='ce-session-cancel']")).toBeInTheDocument();
expect(rows[1].querySelector("[data-testid='ce-session-cancel']")).toBeInTheDocument(); expect(rows[1].querySelector("[data-testid='ce-session-cancel']")).toBeInTheDocument();
expect(rows[2].querySelector("[data-testid='ce-session-cancel']")).not.toBeInTheDocument(); expect(rows[2].querySelector("[data-testid='ce-session-cancel']")).not.toBeInTheDocument();
expect(screen.getByTestId("ce-resume-latest")).toHaveTextContent(/input needed/i);
expect(screen.getAllByTestId("ce-pipeline-stage")).toHaveLength(5);
}); });
it("renders no cancel affordance for an empty sessions list", async () => { it("renders no cancel affordance for an empty sessions list", async () => {
@@ -283,15 +291,32 @@ describe("CompoundEngineeringView", () => {
await screen.findByTestId("ce-sessions"); await screen.findByTestId("ce-sessions");
fireEvent.click(screen.getByTestId("ce-session-open")); fireEvent.click(screen.getByTestId("ce-session-open"));
await screen.findByTestId("ce-flow"); await screen.findByTestId("ce-flow");
expect(new URLSearchParams(window.location.search).get("ceSession")).toBe("flow");
listSessions.mockResolvedValue([mkCeSession({ id: "flow", stage: "plan", status: "interrupted", error: "Cancelled by user" })]); listSessions.mockResolvedValue([mkCeSession({ id: "flow", stage: "plan", status: "interrupted", error: "Cancelled by user" })]);
fireEvent.click(screen.getByTestId("ce-flow-cancel")); fireEvent.click(screen.getByTestId("ce-flow-cancel"));
await waitFor(() => expect(cancelSession).toHaveBeenCalledWith("flow", "p1")); await waitFor(() => expect(cancelSession).toHaveBeenCalledWith("flow", "p1"));
await waitFor(() => expect(screen.queryByTestId("ce-flow")).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByTestId("ce-flow")).not.toBeInTheDocument());
expect(new URLSearchParams(window.location.search).has("ceSession")).toBe(false);
expect(screen.getByTestId("ce-sessions")).toBeInTheDocument(); expect(screen.getByTestId("ce-sessions")).toBeInTheDocument();
expect(screen.getByTestId("ce-session-discard")).toBeInTheDocument(); expect(screen.getByTestId("ce-session-discard")).toBeInTheDocument();
}); });
it("writes a newly launched session to the URL", async () => {
listArtifacts.mockResolvedValue(makeResult({}));
startSession.mockResolvedValue(mkCeSession({ id: "new-strategy", stage: "strategy", status: "active" }));
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
fireEvent.click(await screen.findByTestId("ce-start-action"));
const strategy = (await screen.findAllByTestId("ce-launcher-stage")).find(
(node) => node.getAttribute("data-stage") === "strategy",
)!;
fireEvent.click(strategy);
await screen.findByTestId("ce-flow");
expect(new URLSearchParams(window.location.search).get("ceSession")).toBe("new-strategy");
});
it("discards a terminal session via the list", async () => { it("discards a terminal session via the list", async () => {
listArtifacts.mockResolvedValue(makeResult({})); listArtifacts.mockResolvedValue(makeResult({}));
listSessions.mockResolvedValue([mkCeSession({ id: "done", stage: "plan", status: "completed" })]); listSessions.mockResolvedValue([mkCeSession({ id: "done", stage: "plan", status: "completed" })]);
@@ -300,11 +325,58 @@ describe("CompoundEngineeringView", () => {
await screen.findByTestId("ce-sessions"); await screen.findByTestId("ce-sessions");
listSessions.mockResolvedValue([]); listSessions.mockResolvedValue([]);
fireEvent.click(screen.getByTestId("ce-session-discard")); fireEvent.click(screen.getByTestId("ce-session-discard"));
expect(deleteSession).not.toHaveBeenCalled();
expect(screen.getByTestId("ce-discard-confirm")).toHaveTextContent(/delete permanently/i);
fireEvent.click(screen.getByRole("button", { name: "Delete permanently" }));
await waitFor(() => expect(deleteSession).toHaveBeenCalledWith("done", "p1")); await waitFor(() => expect(deleteSession).toHaveBeenCalledWith("done", "p1"));
await waitFor(() => expect(screen.queryByTestId("ce-sessions")).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByTestId("ce-sessions")).not.toBeInTheDocument());
}); });
it("shows unified plan readiness and derives rail completion from artifacts", async () => {
listArtifacts.mockResolvedValue(makeResult({
plan: [{
kind: "artifact",
id: "plan:docs/plans/ready.md",
stage: "plan",
path: "docs/plans/ready.md",
name: "ready.md",
size: 10,
updatedAt: 2,
artifactContract: "ce-unified-plan/v1",
artifactReadiness: "implementation-ready",
}],
}));
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
expect(await screen.findByText("Implementation ready")).toBeInTheDocument();
const plan = screen.getAllByTestId("ce-pipeline-stage").find((node) => node.getAttribute("data-stage") === "plan")!;
expect(plan).toHaveTextContent("Complete");
const brainstorm = screen.getAllByTestId("ce-pipeline-stage").find((node) => node.getAttribute("data-stage") === "brainstorm")!;
expect(brainstorm).toHaveTextContent("Complete");
});
it("keeps Plan incomplete when only a requirements artifact exists", async () => {
listArtifacts.mockResolvedValue(makeResult({
plan: [{
kind: "artifact",
id: "plan:docs/plans/requirements.md",
stage: "plan",
path: "docs/plans/requirements.md",
name: "requirements.md",
size: 10,
updatedAt: 2,
artifactContract: "ce-unified-plan/v1",
artifactReadiness: "requirements-only",
}],
}));
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
const stages = await screen.findAllByTestId("ce-pipeline-stage");
expect(stages.find((node) => node.getAttribute("data-stage") === "brainstorm")).toHaveTextContent("Complete");
expect(stages.find((node) => node.getAttribute("data-stage") === "plan")).toHaveTextContent("Not started");
});
it("does not fetch when the viewport-gated flag is disabled", async () => { it("does not fetch when the viewport-gated flag is disabled", async () => {
listArtifacts.mockResolvedValue(makeResult({})); listArtifacts.mockResolvedValue(makeResult({}));
render(<CompoundEngineeringView projectId="p1" enabledOverride={false} />); render(<CompoundEngineeringView projectId="p1" enabledOverride={false} />);

View File

@@ -47,24 +47,24 @@ function mkSession(over: Partial<CeSession>): CeSession {
} }
describe("Stage launcher (R4)", () => { describe("Stage launcher (R4)", () => {
it("lists exactly the registered stages", async () => { it("keeps pipeline stages in the launcher and Debug as a separate investigation action", async () => {
render(<CompoundEngineeringView enabledOverride projectId="p1" />); render(<CompoundEngineeringView enabledOverride projectId="p1" />);
// Empty-state start affordance opens the launcher. // Empty-state start affordance opens the launcher.
await waitFor(() => screen.getByTestId("ce-empty-state")); await waitFor(() => screen.getByTestId("ce-empty-state"));
fireEvent.click(screen.getByTestId("ce-start-action")); fireEvent.click(screen.getByTestId("ce-start-action"));
const tiles = await screen.findAllByTestId("ce-launcher-stage"); const tiles = await screen.findAllByTestId("ce-launcher-stage");
const expected = listStages(); const expected = listStages().filter((stage) => stage.stageId !== "debug");
expect(tiles).toHaveLength(expected.length); expect(tiles).toHaveLength(expected.length);
const renderedStages = tiles.map((t) => t.getAttribute("data-stage")).sort(); const renderedStages = tiles.map((t) => t.getAttribute("data-stage")).sort();
expect(renderedStages).toEqual(expected.map((s) => s.stageId).sort()); expect(renderedStages).toEqual(expected.map((s) => s.stageId).sort());
// And the labels match the registry. // And the labels match the registry.
for (const stage of expected) { for (const stage of expected) {
expect(screen.getByText(stage.label)).toBeInTheDocument(); expect(screen.getAllByText(stage.label).length).toBeGreaterThan(0);
} }
const debugTiles = tiles.filter((t) => t.getAttribute("data-stage") === "debug"); const debugTiles = tiles.filter((t) => t.getAttribute("data-stage") === "debug");
expect(debugTiles).toHaveLength(1); expect(debugTiles).toHaveLength(0);
expect(debugTiles[0]).toHaveTextContent("Debug"); expect(screen.getByTestId("ce-investigate")).toHaveTextContent("Investigate");
expect((LucideIcons as unknown as Record<string, unknown>)[getStage("debug")!.icon]).toBeTruthy(); expect((LucideIcons as unknown as Record<string, unknown>)[getStage("debug")!.icon]).toBeTruthy();
}); });

View File

@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const DASHBOARD_ENTRY_FILES = ["../CeFlow.tsx", "../CompoundEngineeringView.tsx"];
describe("Compound Engineering dashboard browser boundary", () => {
it("does not import server-only plugin modules into browser entry files", () => {
/*
FNXC:CompoundEngineeringUI 2026-07-10-23:10:
Dashboard entry modules must stay browser-safe. Importing the server reconciler pulls node:crypto through pipeline-store and crashes the live plugin view even when TypeScript and jsdom tests pass.
*/
for (const relativePath of DASHBOARD_ENTRY_FILES) {
const source = readFileSync(new URL(relativePath, import.meta.url), "utf8");
expect(source).not.toMatch(/from\s+["']\.\.\/sync\//);
expect(source).not.toMatch(/from\s+["']node:/);
}
});
});

View File

@@ -50,12 +50,12 @@ export function getArtifactPreviewUrl(id: string, projectId?: string): string {
/** Start a stage session. Returns the freshly-created session (after one turn). */ /** Start a stage session. Returns the freshly-created session (after one turn). */
export async function startSession( export async function startSession(
stage: string, stage: string,
opts: { message?: string; projectId?: string } = {}, opts: { message?: string; projectId?: string; sourceSessionId?: string } = {},
): Promise<CeSession> { ): Promise<CeSession> {
const data = await request<{ session: CeSession }>(`/sessions`, { const data = await request<{ session: CeSession }>(`/sessions`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ stage, message: opts.message ?? "", projectId: opts.projectId }), body: JSON.stringify({ stage, message: opts.message ?? "", projectId: opts.projectId, sourceSessionId: opts.sourceSessionId }),
}); });
return data.session; return data.session;
} }

View File

@@ -61,7 +61,7 @@ export interface UseCeSessionResult {
/** True while a request (start/answer/resume) is in flight. */ /** True while a request (start/answer/resume) is in flight. */
busy: boolean; busy: boolean;
error?: string; error?: string;
start(stage: string, opts?: { message?: string; projectId?: string }): Promise<void>; start(stage: string, opts?: { message?: string; projectId?: string; sourceSessionId?: string }): Promise<CeSession | undefined>;
/** Adopt an EXISTING session (e.g. from the session list) as the active one. */ /** Adopt an EXISTING session (e.g. from the session list) as the active one. */
open(sessionId: string, opts?: { projectId?: string }): Promise<void>; open(sessionId: string, opts?: { projectId?: string }): Promise<void>;
answer(questionId: string, response: unknown): Promise<void>; answer(questionId: string, response: unknown): Promise<void>;
@@ -116,8 +116,10 @@ export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionRes
try { try {
const next = await op(); const next = await op();
apply(next); apply(next);
return next;
} catch (err) { } catch (err) {
if (mounted.current) setError(err instanceof Error ? err.message : String(err)); if (mounted.current) setError(err instanceof Error ? err.message : String(err));
return undefined;
} finally { } finally {
if (mounted.current) setBusy(false); if (mounted.current) setBusy(false);
} }
@@ -126,7 +128,7 @@ export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionRes
); );
const start = useCallback( const start = useCallback(
(stage: string, opts: { message?: string; projectId?: string } = {}) => { (stage: string, opts: { message?: string; projectId?: string; sourceSessionId?: string } = {}) => {
projectIdRef.current = opts.projectId; projectIdRef.current = opts.projectId;
return run(() => transport.start(stage, opts)); return run(() => transport.start(stage, opts));
}, },
@@ -137,27 +139,27 @@ export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionRes
// as this hook's active session. Like start(), it pins the projectId used for // as this hook's active session. Like start(), it pins the projectId used for
// every subsequent call — the session row lives in that project's store. // every subsequent call — the session row lives in that project's store.
const open = useCallback( const open = useCallback(
(sessionId: string, opts: { projectId?: string } = {}) => { async (sessionId: string, opts: { projectId?: string } = {}) => {
projectIdRef.current = opts.projectId; projectIdRef.current = opts.projectId;
sessionIdRef.current = sessionId; sessionIdRef.current = sessionId;
return run(() => transport.get(sessionId, opts.projectId)); await run(() => transport.get(sessionId, opts.projectId));
}, },
[run, transport], [run, transport],
); );
const answer = useCallback( const answer = useCallback(
(questionId: string, response: unknown) => { async (questionId: string, response: unknown) => {
const id = sessionIdRef.current; const id = sessionIdRef.current;
if (!id) return Promise.resolve(); if (!id) return;
return run(() => transport.answer(id, questionId, response, projectIdRef.current)); await run(() => transport.answer(id, questionId, response, projectIdRef.current));
}, },
[run, transport], [run, transport],
); );
const resume = useCallback(() => { const resume = useCallback(async () => {
const id = sessionIdRef.current; const id = sessionIdRef.current;
if (!id) return Promise.resolve(); if (!id) return;
return run(() => transport.resume(id, projectIdRef.current)); await run(() => transport.resume(id, projectIdRef.current));
}, [run, transport]); }, [run, transport]);
const reset = useCallback(() => { const reset = useCallback(() => {

View File

@@ -46,7 +46,7 @@ export {
CE_PLUGIN_ID, CE_PLUGIN_ID,
CE_WORK_SOURCE_TYPE, CE_WORK_SOURCE_TYPE,
} from "./session/orchestrator.js"; } from "./session/orchestrator.js";
export { getStage, listStages, registerStage } from "./session/stage-registry.js"; export { getStage, listPipelineStages, listStages, registerStage } from "./session/stage-registry.js";
export { export {
settingsSchema, settingsSchema,
getDefaultProvider, getDefaultProvider,

View File

@@ -59,6 +59,7 @@ export function createSessionRoutes(): PluginRouteDefinition[] {
const result = await orch.start(stageId, { const result = await orch.start(stageId, {
openingMessage, openingMessage,
projectId: asString(body?.projectId) ?? null, projectId: asString(body?.projectId) ?? null,
sourceSessionId: asString(body?.sourceSessionId),
detach: true, detach: true,
}); });
return { status: 201, body: { session: result.session } }; return { status: 201, body: { session: result.session } };

View File

@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path"; import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import type { import type {
CreateInteractiveAiSessionFactory, CreateInteractiveAiSessionFactory,
CreateInteractiveAiSessionOptions, CreateInteractiveAiSessionOptions,
@@ -22,6 +22,8 @@ import { getStage, type CeStageDefinition } from "./stage-registry.js";
* the board (U7). Its skill is `ce-work` (see the stage registry). * the board (U7). Its skill is `ce-work` (see the stage registry).
*/ */
export const WORK_STAGE_ID = "work"; export const WORK_STAGE_ID = "work";
const BRAINSTORM_STAGE_ID = "brainstorm";
const PLAN_STAGE_ID = "plan";
/** /**
* CE provenance identity constants. Defined in `../sync/ce-task.ts` (so the * CE provenance identity constants. Defined in `../sync/ce-task.ts` (so the
@@ -223,6 +225,8 @@ export interface StartStageOptions {
/** Opening user message (the stage prompt / topic). */ /** Opening user message (the stage prompt / topic). */
openingMessage: string; openingMessage: string;
projectId?: string | null; projectId?: string | null;
/** Completed predecessor session whose artifact should be handed to the next stage. */
sourceSessionId?: string;
/** /**
* Return as soon as the session row exists, with the turn running in the * Return as soon as the session row exists, with the turn running in the
* background (the route posture — lets clients watch live working output * background (the route posture — lets clients watch live working output
@@ -305,7 +309,7 @@ export class CeOrchestrator {
warnIfStageSkillMissing(this.ctx.logger, stage, additionalSkillPaths); warnIfStageSkillMissing(this.ctx.logger, stage, additionalSkillPaths);
return { return {
cwd: this.projectRoot, cwd: this.projectRoot,
systemPrompt: buildStageSystemPrompt(stage), systemPrompt: this.buildSystemPrompt(stage, sessionId),
tools: "coding", tools: "coding",
requestedSkillNames: [stage.skillId], requestedSkillNames: [stage.skillId],
additionalSkillPaths, additionalSkillPaths,
@@ -320,6 +324,13 @@ export class CeOrchestrator {
}; };
} }
private buildSystemPrompt(stage: CeStageDefinition, sessionId: string): string {
const base = buildStageSystemPrompt(stage);
const artifactPath = this.store.get(sessionId)?.artifactPath;
if (stage.stageId !== PLAN_STAGE_ID || !artifactPath) return base;
return `${base}\n\nThe requirements-only unified plan is at ${artifactPath}. Read it and enrich that exact artifact in place to artifact_readiness: implementation-ready; do not create a sibling plan.`;
}
/** /**
* Live mid-turn visibility. Accumulates streamed deltas into the session's * Live mid-turn visibility. Accumulates streamed deltas into the session's
* activity buffer (consecutive deltas of one kind merge into one turn; tool * activity buffer (consecutive deltas of one kind merge into one turn; tool
@@ -452,9 +463,29 @@ export class CeOrchestrator {
); );
} }
/*
* FNXC:CompoundEngineeringPlanning 2026-07-10-22:52:
* Brainstorm creates the requirements-only unified plan. A same-project Plan session must carry the selected completed predecessor's safe docs/plans artifact path, accept it only while it remains requirements-only, and atomically replace it with valid implementation-ready output; absent a compatible handoff, legacy new-file behavior remains available.
*/
const handoffArtifactPath = stageId === PLAN_STAGE_ID
? this.findBrainstormHandoffArtifact(opts.projectId ?? null, opts.sourceSessionId)
: null;
if (handoffArtifactPath) {
const competingPlan = this.store.list({ stage: PLAN_STAGE_ID }).find((candidate) => (
candidate.projectId === (opts.projectId ?? null)
&& candidate.artifactPath === handoffArtifactPath
&& candidate.status !== "completed"
&& candidate.status !== "error"
&& candidate.status !== "interrupted"
));
if (competingPlan) {
throw new Error(`Plan session ${competingPlan.id} is already enriching ${handoffArtifactPath}`);
}
}
const session = this.store.create({ const session = this.store.create({
stage: stageId, stage: stageId,
projectId: opts.projectId ?? null, projectId: opts.projectId ?? null,
artifactPath: handoffArtifactPath,
turnIntervalMs: this.turnTimeoutMs, turnIntervalMs: this.turnTimeoutMs,
}); });
this.store.appendHistory(session.id, { role: "user", text: opts.openingMessage, at: new Date().toISOString() }); this.store.appendHistory(session.id, { role: "user", text: opts.openingMessage, at: new Date().toISOString() });
@@ -468,6 +499,46 @@ export class CeOrchestrator {
return turn; return turn;
} }
/** Resolve the newest durable same-project Brainstorm handoff accepted for in-place Plan enrichment. */
private findBrainstormHandoffArtifact(projectId: string | null, sourceSessionId?: string): string | null {
const candidates = sourceSessionId
? [this.store.get(sourceSessionId)].filter((session): session is CeSession => Boolean(session))
: this.store.list({ stage: BRAINSTORM_STAGE_ID });
const candidate = candidates.find((session) => (
session.stage === BRAINSTORM_STAGE_ID
&& session.status === "completed"
&& session.projectId === projectId
&& session.artifactPath
&& this.isSafePlanArtifactPath(session.artifactPath)
&& this.isRequirementsOnlyPlanArtifact(session.artifactPath)
));
return candidate?.artifactPath ?? null;
}
private isRequirementsOnlyPlanArtifact(artifactPath: string): boolean {
try {
const prefix = readFileSync(artifactPath, "utf8").slice(0, 8 * 1024);
return /(?:^|\n)artifact_contract:\s*ce-unified-plan\/v1\s*(?:\n|$)/.test(prefix)
&& /(?:^|\n)artifact_readiness:\s*requirements-only\s*(?:\n|$)/.test(prefix);
} catch {
return false;
}
}
/** Handoffs may be absolute (current sessions) or root-relative (legacy rows), but must resolve inside docs/plans. */
private isSafePlanArtifactPath(artifactPath: string): boolean {
const planRoot = resolve(this.projectRoot, getStage(PLAN_STAGE_ID)?.artifactLocation ?? "docs/plans/");
const candidate = isAbsolute(artifactPath) ? resolve(artifactPath) : resolve(this.projectRoot, artifactPath);
const rel = relative(planRoot, candidate);
if (rel.startsWith("..") || isAbsolute(rel) || rel === "") return false;
try {
const realRel = relative(realpathSync(planRoot), realpathSync(candidate));
return realRel !== "" && !realRel.startsWith("..") && !isAbsolute(realRel);
} catch {
return false;
}
}
/** Create the live handle and run the opening turn. Never rejects. */ /** Create the live handle and run the opening turn. Never rejects. */
private async runOpeningTurn( private async runOpeningTurn(
sessionId: string, sessionId: string,
@@ -773,7 +844,17 @@ export class CeOrchestrator {
} }
watchdog.cancel(); watchdog.cancel();
const session = this.applyEvent(sessionId, event); let session: CeSession;
try {
session = this.applyEvent(sessionId, event);
} catch (error) {
session = this.failSession(sessionId, error);
this.disposeLive(sessionId);
return {
session,
event: { type: "error", data: { message: session.error ?? "artifact persistence failed", cause: error } },
};
}
if (event.type === "complete" || event.type === "error") { if (event.type === "complete" || event.type === "error") {
this.disposeLive(sessionId); this.disposeLive(sessionId);
} }
@@ -930,12 +1011,30 @@ export class CeOrchestrator {
const location = stage?.artifactLocation ?? `docs/ce/${session.stage}/`; const location = stage?.artifactLocation ?? `docs/ce/${session.stage}/`;
const content = this.extractArtifactContent(data); const content = this.extractArtifactContent(data);
const target = location.endsWith("/") const target = session.stage === PLAN_STAGE_ID
? join(location, `${session.stage}-${session.id}.md`) && session.artifactPath
: location; && this.isSafePlanArtifactPath(session.artifactPath)
? session.artifactPath
: location.endsWith("/")
? join(location, `${session.stage}-${session.id}.md`)
: location;
const abs = isAbsolute(target) ? target : join(this.projectRoot, target); const abs = isAbsolute(target) ? target : join(this.projectRoot, target);
mkdirSync(dirname(abs), { recursive: true }); mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, content, "utf-8"); if (session.stage === PLAN_STAGE_ID && session.artifactPath === target) {
if (!/(?:^|\n)artifact_contract:\s*ce-unified-plan\/v1\s*(?:\n|$)/.test(content)
|| !/(?:^|\n)artifact_readiness:\s*implementation-ready\s*(?:\n|$)/.test(content)) {
throw new Error("Plan completion must produce an implementation-ready ce-unified-plan/v1 artifact");
}
const temporary = `${abs}.tmp-${session.id}`;
try {
writeFileSync(temporary, content, "utf-8");
renameSync(temporary, abs);
} finally {
if (existsSync(temporary)) unlinkSync(temporary);
}
} else {
writeFileSync(abs, content, "utf-8");
}
return abs; return abs;
} }

View File

@@ -20,6 +20,8 @@ export interface CeStageDefinition {
* earlier; values need not be contiguous (gaps leave room to insert between). * earlier; values need not be contiguous (gaps leave room to insert between).
*/ */
order: number; order: number;
/** Whether this launchable stage participates in automatic progression. Defaults to true. */
participatesInPipeline?: boolean;
/** Bundled skill the orchestrator loads for this stage. */ /** Bundled skill the orchestrator loads for this stage. */
skillId: string; skillId: string;
/** /**
@@ -105,11 +107,12 @@ const STAGE_DEFINITIONS: CeStageDefinition[] = [
}, },
{ {
/* /*
* FNXC:CompoundEngineering 2026-06-16-19:40: * FNXC:CompoundEngineeringPipeline 2026-07-10-22:52:
* debug is an operator-launchable investigation session appended after work so the existing strategy→ideate→brainstorm→plan→work auto-advance chain remains unchanged. * Debug is manually launchable but does not participate in automatic Strategy -> Ideate -> Brainstorm -> Plan -> Work progression. Work remains the terminal automatic stage.
*/ */
stageId: "debug", stageId: "debug",
order: 600, order: 600,
participatesInPipeline: false,
skillId: "ce-debug", skillId: "ce-debug",
artifactLocation: "docs/debug/", artifactLocation: "docs/debug/",
icon: "Bug", icon: "Bug",
@@ -132,6 +135,11 @@ export function listStages(): CeStageDefinition[] {
return [...REGISTRY.values()].sort((a, b) => a.order - b.order || a.stageId.localeCompare(b.stageId)); return [...REGISTRY.values()].sort((a, b) => a.order - b.order || a.stageId.localeCompare(b.stageId));
} }
/** Automatic stages only; manual utility stages remain available through listStages/getStage. */
export function listPipelineStages(): CeStageDefinition[] {
return listStages().filter((stage) => stage.participatesInPipeline !== false);
}
/** /**
* Register an additional stage at runtime (used by tests to prove "adding a * Register an additional stage at runtime (used by tests to prove "adding a
* stage requires only data"). Production stages live in STAGE_DEFINITIONS. * stage requires only data"). Production stages live in STAGE_DEFINITIONS.

View File

@@ -1,5 +1,5 @@
import type { PluginContext, Task } from "@fusion/core"; import type { PluginContext, Task } from "@fusion/core";
import { listStages } from "../session/stage-registry.js"; import { listPipelineStages } from "../session/stage-registry.js";
import { createCeTaskWithLink } from "./ce-task.js"; import { createCeTaskWithLink } from "./ce-task.js";
import { import {
getCePipelineStore, getCePipelineStore,
@@ -57,12 +57,11 @@ export interface ReconcileResult {
/** /**
* The linear CE stage order. The pipeline advances along this sequence. * The linear CE stage order. The pipeline advances along this sequence.
* `listStages()` is sorted by each stage's explicit `order` ordinal (NOT Map * Manual utility stages are excluded; remaining stages are sorted by explicit
* insertion order), so a stage registered out of order — or inserted mid- * `order`, so runtime stages inserted mid-pipeline slot into the right place.
* pipeline later — slots into the correct position here.
*/ */
function stageOrder(): string[] { function stageOrder(): string[] {
return listStages().map((s) => s.stageId); return listPipelineStages().map((s) => s.stageId);
} }
/** The stage AFTER `stageId` in the pipeline, or `undefined` if it's terminal. */ /** The stage AFTER `stageId` in the pipeline, or `undefined` if it's terminal. */