feat(compound-engineering): clarify the pipeline flow
This commit is contained in:
7
.changeset/clean-compound-pipeline.md
Normal file
7
.changeset/clean-compound-pipeline.md
Normal 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.
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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} />);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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:/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user