feat(compound-engineering): align the UI with the upstream loop
This commit is contained in:
@@ -2,6 +2,6 @@
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Support repeatable Compound Engineering cycles with project-scoped progress and artifact collections.
|
||||
summary: Guide repeatable Compound Engineering cycles from product grounding through reusable learnings.
|
||||
category: feature
|
||||
dev: Adds collection-aware stages, safer session controls, explicit choice confirmation, and terminal Work progression.
|
||||
dev: Adds project-scoped sessions, collection-aware stages, Work quality gates, and terminal Compound progression.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -8,20 +8,21 @@ describe("compound engineering stage registry", () => {
|
||||
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, 6)).toEqual(["strategy", "ideate", "brainstorm", "plan", "work", "compound"]);
|
||||
expect(stageIds.at(-1)).toBe("debug");
|
||||
expect(stageIds.filter((stageId) => stageId === "debug")).toHaveLength(1);
|
||||
expect(stageIds.indexOf("plan")).toBeLessThan(stageIds.indexOf("work"));
|
||||
expect(stageIds.indexOf("work")).toBeLessThan(stageIds.indexOf("debug"));
|
||||
expect(pipelineStageIds).toEqual(["strategy", "ideate", "brainstorm", "plan", "work"]);
|
||||
expect(stageIds.indexOf("compound")).toBeLessThan(stageIds.indexOf("debug"));
|
||||
expect(pipelineStageIds).toEqual(["brainstorm", "plan", "work", "compound"]);
|
||||
});
|
||||
|
||||
it("advances through every automatic transition and treats work as terminal", () => {
|
||||
expect(nextStageAfter("strategy")).toBe("ideate");
|
||||
expect(nextStageAfter("ideate")).toBe("brainstorm");
|
||||
it("advances through delivery into compounding and treats compound as terminal", () => {
|
||||
expect(nextStageAfter("strategy")).toBeUndefined();
|
||||
expect(nextStageAfter("ideate")).toBeUndefined();
|
||||
expect(nextStageAfter("brainstorm")).toBe("plan");
|
||||
expect(nextStageAfter("plan")).toBe("work");
|
||||
expect(nextStageAfter("work")).toBeUndefined();
|
||||
expect(nextStageAfter("work")).toBe("compound");
|
||||
expect(nextStageAfter("compound")).toBeUndefined();
|
||||
expect(nextStageAfter("debug")).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -47,7 +48,7 @@ describe("compound engineering stage registry", () => {
|
||||
|
||||
expect(stage).toMatchObject({
|
||||
stageId: "debug",
|
||||
order: 600,
|
||||
order: 700,
|
||||
skillId: "ce-debug",
|
||||
artifactLocation: "docs/debug/",
|
||||
artifactGlob: "docs/debug/**/*.md",
|
||||
|
||||
@@ -183,21 +183,37 @@ describe("U8 reconciler (convergence + outbound)", () => {
|
||||
expect(state.currentStage).toBe("work");
|
||||
});
|
||||
|
||||
it("completing work terminates the automatic pipeline without creating a debug task", async () => {
|
||||
it("completing work advances to Compound 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(1);
|
||||
expect(getCePipelineStore(ctx).getState(cePipelineId)).toMatchObject({
|
||||
currentStage: "compound",
|
||||
status: "awaiting_board",
|
||||
});
|
||||
expect(await taskStore.listTasks()).toHaveLength(before.length + 1);
|
||||
expect(getCePipelineStore(ctx).listByPipeline(cePipelineId).some((link) => link.ceStageId === "debug")).toBe(false);
|
||||
});
|
||||
|
||||
it("completing Compound closes the delivery loop without creating another task", async () => {
|
||||
const { cePipelineId, task } = await landPipeline("compound");
|
||||
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",
|
||||
currentStage: "compound",
|
||||
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 () => {
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("discoverArtifacts", () => {
|
||||
expect(byStage.plan.label).toBe("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.compound.entries).toHaveLength(1);
|
||||
// Every group present is flagged present.
|
||||
expect(byStage.ideate.present).toBe(true);
|
||||
expect(byStage.brainstorm.entries[0]).toMatchObject({ path: "docs/brainstorms/requirements.md" });
|
||||
|
||||
@@ -65,7 +65,7 @@ function stageLocations(): ConventionalLocation[] {
|
||||
const path = definition.artifactLocation.replace(/\/$/, "");
|
||||
return {
|
||||
stage: definition.stageId,
|
||||
label: definition.label,
|
||||
label: definition.stageId === "compound" ? "Learnings" : definition.label,
|
||||
path,
|
||||
kind: definition.artifactLocation.endsWith("/") ? "directory" : "file",
|
||||
};
|
||||
@@ -76,7 +76,6 @@ function conventionalLocations(): ConventionalLocation[] {
|
||||
return [
|
||||
...stageLocations(),
|
||||
{ stage: "brainstorm", label: "Brainstorms", path: "docs/brainstorms", kind: "directory" },
|
||||
{ stage: "solution", label: "Solutions", path: "docs/solutions", kind: "directory" },
|
||||
{ stage: "concepts", label: "Concepts", path: "CONCEPTS.md", kind: "file" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -132,6 +132,11 @@ The shared ViewHeader sits directly above plugin content, so the body needs top
|
||||
gap: calc(var(--space-sm) - var(--space-xs) / 2);
|
||||
}
|
||||
|
||||
/* Keep multi-run histories scannable; older artifacts remain available on demand. */
|
||||
.ce-group-more { border-top: 1px solid var(--border); padding-top: var(--space-xs); }
|
||||
.ce-group-more summary { color: var(--text-muted); cursor: pointer; font-size: 0.74rem; font-weight: 600; }
|
||||
.ce-group-more[open] summary { margin-bottom: var(--space-xs); color: var(--text); }
|
||||
|
||||
.ce-artifact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -748,21 +753,30 @@ The pipeline overview must communicate progression and the next useful action at
|
||||
.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-pipeline-block { display: flex; flex-direction: column; gap: var(--space-xs); }
|
||||
.ce-pipeline-block-heading { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-md); }
|
||||
.ce-pipeline-block-heading h3 { margin: 0; font-size: 0.78rem; font-weight: 650; }
|
||||
.ce-pipeline-block-heading span { color: var(--text-dim); font-size: 0.7rem; }
|
||||
.ce-stage-rail { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); list-style: none; margin: 0; padding: 0; }
|
||||
.ce-stage-rail[data-count="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.ce-stage-rail[data-count="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.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 { display: flex; width: 100%; min-height: calc(var(--space-2xl) * 2.65); 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="current"] .ce-stage-icon,
|
||||
.ce-stage-step[data-status="history"] .ce-stage-icon,
|
||||
.ce-stage-step[data-status="last-run-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-purpose { color: var(--text-muted); font-size: 0.7rem; line-height: 1.25; }
|
||||
.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; }
|
||||
@@ -793,7 +807,8 @@ The pipeline overview must communicate progression and the next useful action at
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ce-pipeline-header { align-items: center; }
|
||||
.ce-stage-rail { grid-template-columns: 1fr; }
|
||||
.ce-stage-rail,
|
||||
.ce-stage-rail[data-count] { 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); }
|
||||
|
||||
@@ -170,15 +170,18 @@ function SessionsPanel({
|
||||
}
|
||||
|
||||
function nextPipelineStageId(stageId: string): string | undefined {
|
||||
if (stageId === "strategy" || stageId === "ideate") return "brainstorm";
|
||||
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";
|
||||
type RailStatus = "Not started" | "Active" | "Needs input" | "Last run complete" | "History" | "Current";
|
||||
|
||||
function artifactsForStage(stageId: string, groups: CeArtifactGroup[]): CeArtifactEntry[] {
|
||||
const entries = groups.find((group) => group.stage === stageId)?.entries ?? [];
|
||||
const entries = groups.find((group) => group.stage === stageId)?.entries
|
||||
?? (stageId === "compound" ? groups.find((group) => group.stage === "solution")?.entries : undefined)
|
||||
?? [];
|
||||
if (stageId === "brainstorm") {
|
||||
const unifiedEntries = groups.find((group) => group.stage === "plan")?.entries ?? [];
|
||||
return [...entries, ...unifiedEntries.filter((entry) => (
|
||||
@@ -200,16 +203,87 @@ function railStatus(stageId: string, sessions: CeSession[], artifact?: CeArtifac
|
||||
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";
|
||||
if (latest?.status === "completed") return "Last run complete";
|
||||
if (artifact?.kind === "artifact") return stageId === "strategy" ? "Current" : "History";
|
||||
return "Not started";
|
||||
}
|
||||
|
||||
const STAGE_PURPOSE: Record<string, string> = {
|
||||
strategy: "Set the product direction",
|
||||
ideate: "Explore high-leverage ideas",
|
||||
brainstorm: "Define what to build",
|
||||
plan: "Decide how to build it",
|
||||
work: "Build, simplify, and review",
|
||||
compound: "Save reusable learnings",
|
||||
};
|
||||
|
||||
function StageRail({
|
||||
stages,
|
||||
groups,
|
||||
sessions,
|
||||
disabled,
|
||||
openFile,
|
||||
onLaunch,
|
||||
onOpenSession,
|
||||
}: {
|
||||
stages: CeStageDefinition[];
|
||||
groups: CeArtifactGroup[];
|
||||
sessions: CeSession[];
|
||||
disabled: boolean;
|
||||
openFile?: PluginDashboardViewContext["openFile"];
|
||||
onLaunch: (stage: CeStageDefinition) => void;
|
||||
onOpenSession: (session: CeSession) => void;
|
||||
}) {
|
||||
return (
|
||||
<ol className="ce-stage-rail" data-count={stages.length}>
|
||||
{stages.map((stage) => {
|
||||
const artifacts = artifactsForStage(stage.stageId, groups);
|
||||
const artifact = artifacts[0];
|
||||
const stageSessions = sessions.filter((session) => session.stage === stage.stageId);
|
||||
const latestSession = stageSessions[0];
|
||||
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().replaceAll(" ", "-")}>
|
||||
<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>
|
||||
{STAGE_PURPOSE[stage.stageId] ? <span className="ce-stage-purpose">{STAGE_PURPOSE[stage.stageId]}</span> : null}
|
||||
<span className="ce-stage-status">
|
||||
{status} · {stageSessions.length} {stageSessions.length === 1 ? "run" : "runs"} · {artifacts.length} {artifacts.length === 1 ? "artifact" : "artifacts"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:CompoundEngineeringUI 2026-07-10-12:00:
|
||||
* Operators need the five-stage compounding loop as the overview's primary navigation. Stages report repeatable run and artifact collections instead of implying one permanent completion; Strategy remains the upstream singleton anchor, while Debug remains a separate investigation action because it is not pipeline progression.
|
||||
* Operators need the optional foundation plus four-stage delivery loop as the overview's primary navigation. Stages report repeatable run and artifact collections instead of implying one permanent completion; Strategy remains the upstream singleton anchor, while Debug remains a separate investigation action because it is not pipeline progression.
|
||||
*
|
||||
* FNXC:CompoundEngineeringUI 2026-07-10-23:40:
|
||||
* Compound Engineering follows the upstream repeatable loop: brainstorm requirements, plan implementation, work, review, compound, then repeat with better context. Brainstorm and Plan therefore expose collection counts and latest activity over multiple unified-plan files rather than a single artifact slot.
|
||||
* Compound Engineering follows the upstream repeatable loop: brainstorm requirements, plan implementation, work through built-in simplification and review, compound the learning, then repeat with better context. Brainstorm and Plan therefore expose collection counts and latest activity over multiple unified-plan files rather than a single artifact slot.
|
||||
*
|
||||
* FNXC:CompoundEngineeringUI 2026-07-11-00:02:
|
||||
* Strategy and Ideate are optional foundation work, not mandatory delivery gates. Visually separate them from the Brainstorm -> Plan -> Work -> Compound loop, and state that Work owns simplification and review so users do not repeat quality stages manually.
|
||||
*/
|
||||
function PipelineOverview({
|
||||
groups,
|
||||
@@ -226,14 +300,16 @@ function PipelineOverview({
|
||||
onLaunch: (stage: CeStageDefinition) => void;
|
||||
onOpenSession: (session: CeSession) => void;
|
||||
}) {
|
||||
const stages = listPipelineStages();
|
||||
const stages = listStages().filter((stage) => stage.stageId !== "debug");
|
||||
const foundationStages = stages.filter((stage) => stage.stageId === "strategy" || stage.stageId === "ideate");
|
||||
const deliveryStages = stages.filter((stage) => stage.stageId !== "strategy" && stage.stageId !== "ideate");
|
||||
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>
|
||||
<h2>Compound Engineering</h2>
|
||||
<p>Plan deeply, ship carefully, and save what you learn.</p>
|
||||
</div>
|
||||
{debug ? (
|
||||
<button type="button" className="btn ce-investigate" data-testid="ce-investigate" disabled={disabled} onClick={() => onLaunch(debug)}>
|
||||
@@ -241,42 +317,14 @@ function PipelineOverview({
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
<ol className="ce-stage-rail">
|
||||
{stages.map((stage) => {
|
||||
const artifacts = artifactsForStage(stage.stageId, groups);
|
||||
const artifact = artifacts[0];
|
||||
const stageSessions = sessions.filter((session) => session.stage === stage.stageId);
|
||||
const latestSession = stageSessions[0];
|
||||
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} · {stageSessions.length} {stageSessions.length === 1 ? "run" : "runs"} · {artifacts.length} {artifacts.length === 1 ? "artifact" : "artifacts"}
|
||||
</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>
|
||||
<div className="ce-pipeline-block">
|
||||
<div className="ce-pipeline-block-heading"><h3>Foundation</h3><span>Optional grounding</span></div>
|
||||
<StageRail stages={foundationStages} groups={groups} sessions={sessions} disabled={disabled} openFile={openFile} onLaunch={onLaunch} onOpenSession={onOpenSession} />
|
||||
</div>
|
||||
<div className="ce-pipeline-block">
|
||||
<div className="ce-pipeline-block-heading"><h3>Delivery loop</h3><span>Repeat for each meaningful change</span></div>
|
||||
<StageRail stages={deliveryStages} groups={groups} sessions={sessions} disabled={disabled} openFile={openFile} onLaunch={onLaunch} onOpenSession={onOpenSession} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -302,11 +350,11 @@ function EmptyState({ onStart }: { onStart: () => void }) {
|
||||
<h3>Start your compounding pipeline</h3>
|
||||
<p>
|
||||
No compound-engineering artifacts found yet. Compound Engineering tracks the documents your
|
||||
pipeline produces — strategy, ideation, brainstorms, plans, solutions, and concepts — as you
|
||||
workflow produces — strategy, ideation, brainstorms, plans, work logs, learnings, and concepts — as you
|
||||
move through each stage.
|
||||
</p>
|
||||
<p className="ce-empty-hint">
|
||||
Begin with a stage and its artifact will appear here, grouped and traceable.
|
||||
Start with Brainstorm when the problem is still fuzzy, Plan when requirements are settled, or Debug for broken behavior.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary" data-testid="ce-start-action" onClick={onStart}>
|
||||
Start a stage
|
||||
@@ -376,8 +424,14 @@ function StageGroup({
|
||||
selectedId?: string;
|
||||
openFile?: PluginDashboardViewContext["openFile"];
|
||||
}) {
|
||||
/*
|
||||
* FNXC:CompoundEngineeringUI 2026-07-11-00:06:
|
||||
* Brainstorm and Plan are repeatable collections that can contain dozens of artifacts. Keep the four newest visible for scanning and disclose the full history on demand so power does not overwhelm the default view.
|
||||
*/
|
||||
const empty = group.entries.length === 0;
|
||||
const singleton = group.stage === "strategy";
|
||||
const visibleEntries = group.entries.slice(0, 4);
|
||||
const remainingEntries = group.entries.slice(4);
|
||||
return (
|
||||
<section className="ce-group card" data-testid="ce-group" data-stage={group.stage} data-layout={singleton ? "singleton" : "collection"} data-empty={empty ? "true" : "false"}>
|
||||
<header className="ce-group-header">
|
||||
@@ -390,7 +444,7 @@ function StageGroup({
|
||||
</p>
|
||||
) : (
|
||||
<ul className="ce-artifact-list">
|
||||
{group.entries.map((entry) => (
|
||||
{visibleEntries.map((entry) => (
|
||||
<ArtifactRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
@@ -401,14 +455,34 @@ function StageGroup({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{remainingEntries.length > 0 ? (
|
||||
<details className="ce-group-more">
|
||||
<summary>Show all {group.entries.length}</summary>
|
||||
<ul className="ce-artifact-list">
|
||||
{remainingEntries.map((entry) => (
|
||||
<ArtifactRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onSelect={onSelect}
|
||||
selected={selectedId === entry.id}
|
||||
openFile={openFile}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function artifactDisplayGroups(groups: CeArtifactGroup[]): CeArtifactGroup[] {
|
||||
const conventionalBrainstorms = groups.find((group) => group.stage === "brainstorm");
|
||||
const hasCompoundGroup = groups.some((group) => group.stage === "compound");
|
||||
return groups.flatMap((group) => {
|
||||
if (group.stage === "brainstorm") return [];
|
||||
if (group.stage === "solution") {
|
||||
return hasCompoundGroup ? [] : [{ ...group, stage: "compound", label: "Learnings" }];
|
||||
}
|
||||
if (group.stage !== "plan") return [group];
|
||||
const brainstorms = group.entries.filter((entry) => (
|
||||
entry.kind === "artifact"
|
||||
@@ -641,7 +715,7 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
|
||||
) : null}
|
||||
|
||||
{launcherOpen ? (
|
||||
<StageLauncher stages={listPipelineStages()} disabled={ceSession.busy} onLaunch={onLaunch} />
|
||||
<StageLauncher stages={listStages().filter((stage) => stage.stageId !== "debug")} disabled={ceSession.busy} onLaunch={onLaunch} />
|
||||
) : null}
|
||||
|
||||
<SessionsPanel
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { DiscoveryResult } from "../../artifacts/discovery.js";
|
||||
|
||||
// Mock the network layer so the view renders from seeded discovery results.
|
||||
@@ -54,10 +54,11 @@ function mkCeSession(over: Partial<CeSession>): CeSession {
|
||||
const ALL_STAGES: Array<{ stage: DiscoveryResult["groups"][number]["stage"]; label: string }> = [
|
||||
{ stage: "strategy", label: "Strategy" },
|
||||
{ stage: "ideate", label: "Ideate" },
|
||||
{ stage: "plan", label: "Brainstorm / Plan" },
|
||||
{ stage: "brainstorm", label: "Brainstorms" },
|
||||
{ stage: "plan", label: "Plan" },
|
||||
{ stage: "work", label: "Work" },
|
||||
{ stage: "debug", label: "Debug" },
|
||||
{ stage: "solution", label: "Solutions" },
|
||||
{ stage: "compound", label: "Learnings" },
|
||||
{ stage: "concepts", label: "Concepts" },
|
||||
];
|
||||
|
||||
@@ -155,6 +156,52 @@ describe("CompoundEngineeringView", () => {
|
||||
expect(plans.querySelectorAll('[data-testid="ce-artifact"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps long artifact histories collapsed after the four newest entries", async () => {
|
||||
listArtifacts.mockResolvedValue(
|
||||
makeResult({
|
||||
plan: Array.from({ length: 6 }, (_, index) => ({
|
||||
kind: "artifact" as const,
|
||||
id: `plan:docs/plans/${index}.md`,
|
||||
stage: "plan",
|
||||
path: `docs/plans/${index}.md`,
|
||||
name: `${index}.md`,
|
||||
size: 5,
|
||||
updatedAt: 6 - index,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
const plans = await screen.findByRole("heading", { name: "Plans" });
|
||||
const group = plans.closest<HTMLElement>("[data-testid='ce-group']")!;
|
||||
expect(group.querySelectorAll("[data-testid='ce-artifact']")).toHaveLength(6);
|
||||
const disclosure = within(group).getByText("Show all 6").closest("details")!;
|
||||
expect(disclosure).not.toHaveAttribute("open");
|
||||
fireEvent.click(within(group).getByText("Show all 6"));
|
||||
expect(disclosure).toHaveAttribute("open");
|
||||
});
|
||||
|
||||
it("normalizes legacy solution groups into Compound learnings", async () => {
|
||||
const result = makeResult({});
|
||||
result.groups = result.groups.filter((group) => group.stage !== "compound");
|
||||
result.groups.push({
|
||||
stage: "solution",
|
||||
label: "Solutions",
|
||||
present: true,
|
||||
entries: [{ kind: "artifact", id: "solution:docs/solutions/learned.md", stage: "solution", path: "docs/solutions/learned.md", name: "learned.md", size: 5, updatedAt: 1 }],
|
||||
});
|
||||
result.totalArtifacts = 1;
|
||||
listArtifacts.mockResolvedValue(result);
|
||||
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Learnings" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Solutions" })).not.toBeInTheDocument();
|
||||
const compound = screen.getAllByTestId("ce-pipeline-stage").find((node) => node.getAttribute("data-stage") === "compound");
|
||||
expect(compound).toHaveTextContent("1 artifact");
|
||||
});
|
||||
|
||||
it("opens an artifact in the built-in file viewer via context.openFile", async () => {
|
||||
const openFile = vi.fn();
|
||||
listArtifacts.mockResolvedValue(
|
||||
@@ -243,7 +290,10 @@ describe("CompoundEngineeringView", () => {
|
||||
expect(rows[1].querySelector("[data-testid='ce-session-cancel']")).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);
|
||||
expect(screen.getAllByTestId("ce-pipeline-stage")).toHaveLength(6);
|
||||
expect(screen.getByText("Foundation")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delivery loop")).toBeInTheDocument();
|
||||
expect(screen.getByText("Build, simplify, and review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no cancel affordance for an empty sessions list", async () => {
|
||||
@@ -291,6 +341,19 @@ describe("CompoundEngineeringView", () => {
|
||||
expect(deleteSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hands optional foundation sessions directly into Brainstorm", async () => {
|
||||
const strategy = mkCeSession({ id: "strategy-done", stage: "strategy", status: "completed" });
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
listSessions.mockResolvedValue([strategy]);
|
||||
getSession.mockResolvedValue(strategy);
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
fireEvent.click((await screen.findAllByTestId("ce-session-open"))[0]);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Start Brainstorm" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Start Ideate" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cancels an in-flight session via the list", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
listSessions.mockResolvedValue([mkCeSession({ id: "running", stage: "plan", status: "active" })]);
|
||||
@@ -356,7 +419,7 @@ describe("CompoundEngineeringView", () => {
|
||||
await waitFor(() => expect(screen.queryByTestId("ce-sessions")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("shows unified plan readiness and derives rail completion from artifacts", async () => {
|
||||
it("shows unified plan readiness and distinguishes artifact history from completed runs", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({
|
||||
plan: [{
|
||||
kind: "artifact",
|
||||
@@ -374,9 +437,9 @@ describe("CompoundEngineeringView", () => {
|
||||
|
||||
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");
|
||||
expect(plan).toHaveTextContent("History");
|
||||
const brainstorm = screen.getAllByTestId("ce-pipeline-stage").find((node) => node.getAttribute("data-stage") === "brainstorm")!;
|
||||
expect(brainstorm).toHaveTextContent("Complete");
|
||||
expect(brainstorm).toHaveTextContent("History");
|
||||
});
|
||||
|
||||
it("keeps Plan incomplete when only a requirements artifact exists", async () => {
|
||||
@@ -396,7 +459,7 @@ describe("CompoundEngineeringView", () => {
|
||||
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") === "brainstorm")).toHaveTextContent("History");
|
||||
expect(stages.find((node) => node.getAttribute("data-stage") === "plan")).toHaveTextContent("Not started");
|
||||
});
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ const STAGE_DEFINITIONS: CeStageDefinition[] = [
|
||||
{
|
||||
stageId: "strategy",
|
||||
order: 100,
|
||||
participatesInPipeline: false,
|
||||
skillId: "ce-strategy",
|
||||
artifactLocation: "STRATEGY.md",
|
||||
icon: "Compass",
|
||||
@@ -64,6 +65,7 @@ const STAGE_DEFINITIONS: CeStageDefinition[] = [
|
||||
{
|
||||
stageId: "ideate",
|
||||
order: 200,
|
||||
participatesInPipeline: false,
|
||||
skillId: "ce-ideate",
|
||||
artifactLocation: "docs/ideation/",
|
||||
icon: "Lightbulb",
|
||||
@@ -105,13 +107,26 @@ const STAGE_DEFINITIONS: CeStageDefinition[] = [
|
||||
label: "Work",
|
||||
artifactGlob: "docs/work/**/*.md",
|
||||
},
|
||||
{
|
||||
/*
|
||||
* FNXC:CompoundEngineeringPipeline 2026-07-11-00:02:
|
||||
* The upstream ce-work skill already owns implementation, phase-boundary simplification, and code review. Compound is the next distinct durable stage, saving reusable learnings after delivery instead of ending the user journey at Work.
|
||||
*/
|
||||
stageId: "compound",
|
||||
order: 600,
|
||||
skillId: "ce-compound",
|
||||
artifactLocation: "docs/solutions/",
|
||||
icon: "BookOpenCheck",
|
||||
label: "Compound",
|
||||
artifactGlob: "docs/solutions/**/*.md",
|
||||
},
|
||||
{
|
||||
/*
|
||||
* FNXC:CompoundEngineeringPipeline 2026-07-10-22:52:
|
||||
* Debug is manually launchable but does not participate in automatic Strategy -> Ideate -> Brainstorm -> Plan -> Work progression. Work remains the terminal automatic stage.
|
||||
* Debug is manually launchable but does not participate in automatic Strategy -> Ideate -> Brainstorm -> Plan -> Work -> Compound progression. Compound is the terminal automatic stage.
|
||||
*/
|
||||
stageId: "debug",
|
||||
order: 600,
|
||||
order: 700,
|
||||
participatesInPipeline: false,
|
||||
skillId: "ce-debug",
|
||||
artifactLocation: "docs/debug/",
|
||||
|
||||
Reference in New Issue
Block a user