FN-7584: register builtin:brainstorming workflow with ask-user/exit-gate loop

Adds a discoverable built-in Brainstorming workflow composing the ask-user + exit-gate reach-out loop ahead of the standard coding plan/execute/review/merge spine, plus a WorkflowNodeEditor fix so clearing the ask-user question textarea deletes the config key instead of persisting an empty string.

- Add packages/core/src/builtin-brainstorming-workflow-ir.ts registering builtin:brainstorming (non-default, default-enabled): ask-user -> refine prompt -> exit-gate-on-approval ahead of the unmodified Coding plan/execute/review/merge spine
- Wire the new builtin into packages/core/src/builtin-workflows.ts and extend the builtin-workflows parity test suite
- Add builtin-brainstorming-workflow-ir.test.ts covering the new workflow's IR shape and validation
- Fix WorkflowNodeEditor.tsx ask-user question textarea onChange to delete the config.question key when cleared to empty (validateAskUserAndExitGateNodes rejects present-but-empty question; only an absent key falls back to the engine default)
- Update docs/workflow-steps.md to document builtin:brainstorming as a selectable built-in composition
- Add .changeset/fn-7584-brainstorming-builtin.md (minor, feature)

Files changed:
 .changeset/fn-7584-brainstorming-builtin.md        |   7 ++
 docs/workflow-steps.md                             |   2 +-
 .../builtin-brainstorming-workflow-ir.test.ts      |  79 ++++++++++++++++
 .../core/src/__tests__/builtin-workflows.test.ts   |  66 +++++++++++++
 .../core/src/builtin-brainstorming-workflow-ir.ts  | 103 +++++++++++++++++++++
 packages/core/src/builtin-workflows.ts             |  46 +++++++++
 .../app/components/WorkflowNodeEditor.tsx          |  18 +++-
 7 files changed, 319 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7584

Fusion-Task-Lineage: 2c0258c2-9a35-403a-8688-ee49393a7231

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 11:19:40 -07:00
parent ab28e7811c
commit 53fe0d71b9
7 changed files with 319 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a built-in "Brainstorming" workflow that talks to you before planning.
category: feature
dev: Registers `builtin:brainstorming` (non-default, default-enabled) composing FN-7579's `ask-user` → refine → `exit-gate`-on-approval phase ahead of the normal coding plan/execute/review/merge spine. Parity suite (`builtin-workflows.test.ts`) extended for the new entry.

View File

@@ -382,7 +382,7 @@ start → ask (ask-user: "Anything to refine?")
the PR review loop)
```
Each turn, the user is asked to refine; once they reply "looks good" (or whatever the condition matches), the exit-gate routes the task out of the brainstorm loop. This is a documented composition, not a registered built-in workflow — copy the shape into a custom workflow's IR via `fn_workflow_create`/`fn_workflow_update`.
Each turn, the user is asked to refine; once they reply "looks good" (or whatever the condition matches), the exit-gate routes the task out of the brainstorm loop. This composition is also available as a discoverable built-in: `builtin:brainstorming` (FN-7584) registers exactly this shape — `ask-user` → a refine prompt step → `exit-gate`-on-approval — ahead of the unmodified default Coding plan/execute/review/merge spine, selectable directly from the workflow picker. Copy the shape into a custom workflow's IR via `fn_workflow_create`/`fn_workflow_update` when you need a different downstream pipeline than the standard coding one.
#### Workflow-defined custom task fields

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { BUILTIN_BRAINSTORMING_WORKFLOW_IR } from "../builtin-brainstorming-workflow-ir.js";
import { parseWorkflowIr, serializeWorkflowIr } from "../workflow-ir.js";
import type { WorkflowIrV2 } from "../workflow-ir-types.js";
/*
FNXC:WorkflowBrainstorming 2026-07-05-00:00:
FN-7584 registers builtin:brainstorming as a discoverable built-in composing
FN-7579's ask-user -> refine -> exit-gate-on-approval phase ahead of the normal
coding plan/execute path. These tests pin the IR shape: it parses, orders the
brainstorm nodes before the plan/execute spine, and round-trips through
serialize/parse.
*/
describe("builtin brainstorming workflow IR", () => {
it("parses through parseWorkflowIr", () => {
expect(() => parseWorkflowIr(BUILTIN_BRAINSTORMING_WORKFLOW_IR)).not.toThrow();
const ir = BUILTIN_BRAINSTORMING_WORKFLOW_IR as WorkflowIrV2;
expect(ir.version).toBe("v2");
});
it("contains an ask-user and an exit-gate node ordered before the plan/execute spine", () => {
const ir = BUILTIN_BRAINSTORMING_WORKFLOW_IR as WorkflowIrV2;
const askUser = ir.nodes.find((node) => node.kind === "ask-user");
const exitGate = ir.nodes.find((node) => node.kind === "exit-gate");
expect(askUser?.id).toBe("brainstorm-ask");
expect(exitGate?.id).toBe("brainstorm-exit");
const startIndex = ir.nodes.findIndex((node) => node.id === "start");
const askIndex = ir.nodes.findIndex((node) => node.id === "brainstorm-ask");
const refineIndex = ir.nodes.findIndex((node) => node.id === "brainstorm-refine");
const exitIndex = ir.nodes.findIndex((node) => node.id === "brainstorm-exit");
const planIndex = ir.nodes.findIndex((node) => node.id === "plan");
const parseIndex = ir.nodes.findIndex((node) => node.id === "parse");
const stepsIndex = ir.nodes.findIndex((node) => node.id === "steps");
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(askIndex).toBeGreaterThan(startIndex);
expect(refineIndex).toBeGreaterThan(askIndex);
expect(exitIndex).toBeGreaterThan(refineIndex);
expect(planIndex).toBeGreaterThan(exitIndex);
expect(parseIndex).toBeGreaterThan(planIndex);
expect(stepsIndex).toBeGreaterThan(parseIndex);
// Graph wiring: start feeds the brainstorm loop, and the exit-gate's
// outcome:exit edge is what rejoins the unmodified plan/execute spine.
expect(ir.edges).toContainEqual({ from: "start", to: "brainstorm-ask", condition: "success" });
expect(ir.edges).toContainEqual({ from: "brainstorm-ask", to: "brainstorm-refine", condition: "success" });
expect(ir.edges).toContainEqual({ from: "brainstorm-refine", to: "brainstorm-exit", condition: "success" });
expect(ir.edges).toContainEqual({ from: "brainstorm-exit", to: "plan", condition: "outcome:exit" });
expect(ir.edges).toContainEqual({
from: "brainstorm-exit",
to: "brainstorm-ask",
condition: "outcome:continue",
kind: "rework",
});
// The exit-gate's rework edge targets a declared top-level rework-region head.
expect(askUser?.config?.reworkRegion).toBe(true);
expect(typeof askUser?.config?.maxReworkCycles).toBe("number");
// ask-user carries a non-empty question (validator-enforced when present).
expect(typeof askUser?.config?.question).toBe("string");
expect((askUser?.config?.question as string).trim().length).toBeGreaterThan(0);
});
it("round-trips serialize -> parse to identical bytes", () => {
const serialized = serializeWorkflowIr(BUILTIN_BRAINSTORMING_WORKFLOW_IR);
const reparsed = parseWorkflowIr(serialized);
expect(serializeWorkflowIr(reparsed)).toBe(serialized);
});
it("carries the shared built-in workflow settings", () => {
const ir = BUILTIN_BRAINSTORMING_WORKFLOW_IR as WorkflowIrV2;
expect(ir.settings?.some((setting) => setting.id === "planReviewMaxRevisions")).toBe(true);
expect(ir.settings?.some((setting) => setting.id === "codeReviewMaxRevisions")).toBe(true);
});
});

View File

@@ -343,6 +343,72 @@ describe("built-in workflows", () => {
);
});
/*
* FNXC:WorkflowBrainstorming 2026-07-05-00:00:
* FN-7584 parity coverage for the registered builtin:brainstorming built-in
* (FN-7579's ask-user -> refine -> exit-gate-on-approval composition,
* discoverable from the workflow picker ahead of the normal coding spine).
*/
it("registers builtin:brainstorming as a default-enabled workflow ordered after the existing built-ins", () => {
const brainstorming = getBuiltinWorkflow("builtin:brainstorming");
expect(brainstorming).toBeDefined();
expect(brainstorming!.kind).toBe("workflow");
expect(() => parseWorkflowIr(brainstorming!.ir)).not.toThrow();
expect(defaultEnabledBuiltinWorkflowIds()).toContain("builtin:brainstorming");
expect(BUILTIN_WORKFLOWS.findIndex((workflow) => workflow.id === "builtin:brainstorming")).toBeGreaterThan(
BUILTIN_WORKFLOWS.findIndex((workflow) => workflow.id === "builtin:lead-generation"),
);
// Ordering assertion the suite pins elsewhere (`.slice(0, 5)`) must stay untouched.
expect(defaultEnabledBuiltinWorkflowIds().slice(0, 5)).toEqual([
"builtin:coding",
"builtin:coding-ideas",
"builtin:legacy-coding",
"builtin:quick-fix",
"builtin:review-heavy",
]);
});
it("orders builtin:brainstorming's ask-user/exit-gate loop ahead of the plan/execute spine", () => {
const brainstorming = getBuiltinWorkflow("builtin:brainstorming")!;
const nodes = brainstorming.ir.nodes;
const askIndex = nodes.findIndex((node) => node.kind === "ask-user");
const exitIndex = nodes.findIndex((node) => node.kind === "exit-gate");
const planIndex = nodes.findIndex((node) => node.id === "plan");
const parseIndex = nodes.findIndex((node) => node.id === "parse");
const stepsIndex = nodes.findIndex((node) => node.id === "steps");
expect(askIndex).toBeGreaterThanOrEqual(0);
expect(exitIndex).toBeGreaterThan(askIndex);
expect(planIndex).toBeGreaterThan(exitIndex);
expect(parseIndex).toBeGreaterThan(planIndex);
expect(stepsIndex).toBeGreaterThan(parseIndex);
});
it("builtin:brainstorming still satisfies every merge-capable built-in invariant (completion summary, post-merge-verification, merge primitives, settings)", () => {
const brainstorming = getBuiltinWorkflow("builtin:brainstorming")!;
const summaryNodes = brainstorming.ir.nodes.filter(
(node) => node.kind === "prompt" && (node.config as { summaryTarget?: unknown } | undefined)?.summaryTarget === "task",
);
expect(summaryNodes).toHaveLength(1);
expect((summaryNodes[0]!.config as { toolMode?: unknown }).toolMode).toBe("readonly");
expect(brainstorming.ir.nodes.some((node) => node.id === "post-merge-verification")).toBe(true);
for (const id of [
"merge-gate",
"merge-retry",
"merge-manual-hold",
"branch-group-member-integration",
"branch-group-promotion",
"merge-attempt",
"recovery-router",
]) {
expect(brainstorming.ir.nodes.some((node) => node.id === id)).toBe(true);
}
expect(brainstorming.ir.settings?.some((setting) => setting.id === "planReviewMaxRevisions")).toBe(true);
expect(brainstorming.ir.settings?.some((setting) => setting.id === "codeReviewMaxRevisions")).toBe(true);
});
it("default workflow column ids equal the legacy enum values, in legacy order (KTD-1)", () => {
expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2");
if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2");

View File

@@ -0,0 +1,103 @@
import type { WorkflowIr, WorkflowIrV2 } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
import { BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR } from "./builtin-stepwise-final-review-coding-workflow-ir.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
/*
FNXC:WorkflowBrainstorming 2026-07-05-00:00:
FN-7584 closes the loop FN-7579 opened: `ask-user` and `exit-gate` node kinds
existed only as a documented composition (docs/workflow-steps.md, "Brainstorming
/ chat reach-out composition"), not a discoverable built-in. This module clones
the default Coding graph (BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR,
the same IR backing `builtin:coding`) and prepends a bounded brainstorming phase
between `start` and `plan`:
start -> brainstorm-ask (ask-user: "what do you want to brainstorm?")
-> brainstorm-refine (prompt, seam:"planning": turn the conversation
so far into a refined brief)
-> brainstorm-exit (exit-gate: condition on the ask-user's answer
containing an approval phrase)
outcome:exit -> plan (rejoins the unmodified coding spine)
outcome:continue -> brainstorm-ask (rework edge; the ask-user node
carries config.reworkRegion:true so this is a
legal top-level rework-region head per the U6
convention validateWorkflowIr enforces)
The reused coding spine (parse-steps/foreach/step-review, optional
browser-verification/code-review groups, completion-summary,
post-merge-verification, and the full merge-primitive region) is left byte-for-byte
identical to `builtin:coding`'s graph, so every parity invariant that graph
already satisfies carries over unchanged; only the three new brainstorm nodes
and their four edges are new surface for the parity suite to cover.
*/
const APPROVAL_PHRASE = "looks good";
const RAW_BUILTIN_BRAINSTORMING_WORKFLOW_IR: WorkflowIr = (() => {
const ir = JSON.parse(JSON.stringify(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR)) as WorkflowIrV2;
ir.name = "builtin-brainstorming";
// Insert the three brainstorm nodes right after `start` (order is cosmetic —
// edges determine the walk — but keeping them near `start` mirrors the
// reading order of the graph).
const startIndex = ir.nodes.findIndex((node) => node.id === "start");
if (startIndex < 0) {
throw new Error("brainstorming built-in requires the cloned graph's start node");
}
const startColumn = ir.nodes[startIndex]!.column;
const planNode = ir.nodes.find((node) => node.id === "plan");
const brainstormColumn = planNode?.column ?? startColumn;
ir.nodes.splice(
startIndex + 1,
0,
{
id: "brainstorm-ask",
kind: "ask-user",
column: brainstormColumn,
config: {
question:
`What would you like to brainstorm? Share your idea and any constraints; reply "${APPROVAL_PHRASE}" once you're ready to move into planning.`,
// FNXC:WorkflowBrainstorming 2026-07-05-00:00: top-level rework-region
// head (U6 convention) — the loop bound the exit-gate's rework edge
// resets to when it targets this node.
reworkRegion: true,
maxReworkCycles: 5,
},
},
{
id: "brainstorm-refine",
kind: "prompt",
column: brainstormColumn,
config: {
seam: "planning",
name: "Refine brainstorm",
prompt:
"You are helping the user brainstorm before this task is planned. Read the task description and the user's latest reply. Turn the conversation so far into a short, structured refined brief: 1) the core idea/goal, 2) key constraints or requirements mentioned so far, 3) open questions still unresolved, 4) a one-line readiness assessment (ready to plan, or what's still missing). Keep it concise — this is a running scratchpad the user reviews each turn, not a final spec.",
},
},
{
id: "brainstorm-exit",
kind: "exit-gate",
column: brainstormColumn,
config: {
condition: { type: "output-contains", nodeId: "brainstorm-ask", value: APPROVAL_PHRASE },
},
},
);
// Rewire start -> plan through the new brainstorm loop.
ir.edges = ir.edges.filter((edge) => !(edge.from === "start" && edge.to === "plan"));
ir.edges.unshift(
{ from: "start", to: "brainstorm-ask", condition: "success" },
{ from: "brainstorm-ask", to: "brainstorm-refine", condition: "success" },
{ from: "brainstorm-refine", to: "brainstorm-exit", condition: "success" },
{ from: "brainstorm-exit", to: "plan", condition: "outcome:exit" },
{ from: "brainstorm-exit", to: "brainstorm-ask", condition: "outcome:continue", kind: "rework" },
);
ir.settings = BUILTIN_WORKFLOW_SETTINGS;
return ir;
})();
export const BUILTIN_BRAINSTORMING_WORKFLOW_IR = parseWorkflowIr(RAW_BUILTIN_BRAINSTORMING_WORKFLOW_IR);

View File

@@ -1,5 +1,6 @@
import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
import { BUILTIN_CODING_IDEAS_WORKFLOW_IR } from "./builtin-coding-ideas-workflow-ir.js";
import { BUILTIN_BRAINSTORMING_WORKFLOW_IR } from "./builtin-brainstorming-workflow-ir.js";
import { BUILTIN_LEAD_GENERATION_WORKFLOW_IR } from "./builtin-lead-generation-workflow-ir.js";
import { BUILTIN_MARKETING_WORKFLOW_IR } from "./builtin-marketing-workflow-ir.js";
import { BUILTIN_PR_WORKFLOW_IR } from "./builtin-pr-workflow-ir.js";
@@ -708,6 +709,51 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
createdAt: BUILTIN_TS,
updatedAt: BUILTIN_TS,
},
/*
* FNXC:WorkflowBrainstorming 2026-07-05-00:00:
* FN-7584 registers a discoverable built-in for FN-7579's ask-user -> refine ->
* exit-gate-on-approval brainstorming composition (docs/workflow-steps.md). It
* clones the default Coding graph (BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR)
* and prepends the brainstorm loop between `start` and `plan`, so every downstream
* node/edge is byte-identical to `builtin:coding`. Appended LAST (after
* lead-generation) so it does not disturb the defaultEnabledBuiltinWorkflowIds()
* `.slice(0, 5)` ordering assertion in builtin-workflows.test.ts.
*/
{
id: "builtin:brainstorming",
name: "Brainstorming",
description:
"Brainstorm with the user first: ask a question, refine the idea, and exit the loop on approval, then run the standard coding plan/execute/review/merge pipeline.",
kind: "workflow",
ir: BUILTIN_BRAINSTORMING_WORKFLOW_IR,
layout: {
start: { x: 60, y: 160 },
"brainstorm-ask": { x: 230, y: 160 },
"brainstorm-refine": { x: 400, y: 160 },
"brainstorm-exit": { x: 570, y: 160 },
plan: { x: 740, y: 160 },
"plan-review": { x: 910, y: 160 },
"plan-replan": { x: 910, y: 320 },
parse: { x: 1080, y: 160 },
steps: { x: 1250, y: 160 },
"browser-verification": { x: 1420, y: 160 },
"browser-verification-remediation": { x: 1420, y: 320 },
"code-review": { x: 1590, y: 160 },
"code-review-remediation": { x: 1590, y: 320 },
"completion-summary": { x: 1760, y: 160 },
"merge-gate": { x: 1930, y: 160 },
"branch-group-member-integration": { x: 2100, y: 80 },
"branch-group-promotion": { x: 2270, y: 80 },
"merge-attempt": { x: 2440, y: 160 },
"merge-retry": { x: 2610, y: 80 },
"recovery-router": { x: 2610, y: 240 },
"merge-manual-hold": { x: 2100, y: 240 },
"post-merge-verification": { x: 2780, y: 160 },
end: { x: 2950, y: 160 },
},
createdAt: BUILTIN_TS,
updatedAt: BUILTIN_TS,
},
];
const BUILTIN_BY_ID = new Map(BUILTIN_WORKFLOWS.map((wf) => [wf.id, wf]));

View File

@@ -4754,7 +4754,23 @@ function InnerEditor({
"This workflow is waiting for your input.",
)}
value={String(selectedNode.data.config?.question ?? "")}
onChange={(e) => updateSelectedData({ config: { question: e.target.value } })}
onChange={(e) => {
const value = e.target.value;
// FNXC:WorkflowAskUser 2026-07-05-02:00: validateAskUserAndExitGateNodes
// rejects a PRESENT-but-empty `question` (only an ABSENT question falls
// back to the engine default). Clearing the textarea back to "" must
// therefore delete the key, not persist `question: ""`, or the node
// silently becomes unsavable the moment an author reverts to the
// documented "leave blank for the default" behavior.
updateSelectedData({
config: (prev) => {
const next = { ...prev };
if (value.trim() === "") delete next.question;
else next.question = value;
return next;
},
});
}}
/>
</label>
<p className="wf-inspector-note wf-inspector-note--info">