From 396ed22a029e33b9b27380a85b33ba548822160a Mon Sep 17 00:00:00 2001
From: Ryan Dombrowski
Date: Wed, 12 Aug 2026 08:58:02 -0400
Subject: [PATCH 1/2] =?UTF-8?q?test:=20fail-first=20specs=20for=20P4=20Pha?=
=?UTF-8?q?se=20C=20=E2=80=94=20flow=20decomposition,=20pending=20steps,?=
=?UTF-8?q?=20Build-a-flow=20e2e?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New assertions, run against main (A+B merged, studio#79) — all fail for the
right reasons; NO live gateway call anywhere (the decomposition request is
tested shape-only, the plan.test.ts discipline):
- packages/composer-core/src/flow-plan.test.ts (new) — buildFlowPlanRequest
(goal as the user message; system names the design system, lists intent
ids, states the 2–8 ordered ONE-screen-per-step rules, forbids invented
steps; schema: steps minItems 2 / maxItems 8, closed objects, intent as a
REAL-id enum, optional per step), reconcileFlowPlan (valid plan kept
source "model"; invalid/missing step intent -> planDeterministic on THAT
step's goal; bounds 8 steps + name/title/goal slices; <2 usable steps or
garbage -> deterministic fallback, never an empty plan), and
flowPlanDeterministic (a Gateway-#12-shaped numbered lifecycle fixture ->
>=4 ordered steps, real intents, labeled scripted with a deterministic
reason; sentence clustering; single-sentence goals still outline >=2
steps; rambling goals bounded to 6).
- apps/composer/app/flows.test.ts — surfaceId "" is a PENDING step: flowLint
warns "not built yet" (code pending-step) instead of the dangling error,
one cause one finding (no advanceOn warn on a pending step); parseFlow
shape anchor ("" is a state, not malformation).
- packages/composer-core/src/flows.test.ts — flowSchema/parseFlows anchor:
an empty surfaceId parses (already true; pinned so it stays true).
- e2e/composer-prod-smoke.spec.ts — one added spec: default Build render has
NO flow-mode elements; toggle -> 3-sentence goal -> deterministic labeled
plan -> edit (remove a step, retitle, pin intents) -> "Create flow & build
steps" -> flow exists PENDING -> two sequential ordinary scripted builds ->
accept turn 1 (pre-targeted by the driver) -> Preview shows the half-built
flow with the pending step disabled + the pending body state -> accept
turn 2 -> the completed flow walks end to end.
Pre-change output:
composer-core (npx vitest run src/flow-plan.test.ts src/flows.test.ts):
✓ src/flows.test.ts (7 tests) [pending-shape anchors hold]
FAIL src/flow-plan.test.ts [ src/flow-plan.test.ts ]
Error: Cannot find module './flow-plan'
apps/composer (npx vitest run app/flows.test.ts): 2 failed | 30 passed
× an EMPTY surfaceId is a PENDING step (Phase C): warn 'not built yet', never a dangling error
→ expected 'error' to be 'warn'
× a pending step never also warns about its advanceOn (one cause, one finding)
→ expected 'dangling-surface' to be 'pending-step'
e2e smoke (-g "build a flow", against the freshly built static export of main):
Error: locator.click: ... waiting for getByTestId('build-mode-flow')
1 failed
Note: the Gateway #12 corpus text lives outside this repo; the fixture
mirrors its shape (one workflow goal, numbered stations) and is labeled so.
Co-Authored-By: Claude Fable 5
---
apps/composer/app/flows.test.ts | 26 +++
e2e/composer-prod-smoke.spec.ts | 62 ++++++
packages/composer-core/src/flow-plan.test.ts | 189 +++++++++++++++++++
packages/composer-core/src/flows.test.ts | 9 +
4 files changed, 286 insertions(+)
create mode 100644 packages/composer-core/src/flow-plan.test.ts
diff --git a/apps/composer/app/flows.test.ts b/apps/composer/app/flows.test.ts
index 3a79273..cdd0386 100644
--- a/apps/composer/app/flows.test.ts
+++ b/apps/composer/app/flows.test.ts
@@ -358,6 +358,32 @@ describe("flowLint — reference validation with the finding() shape, gate 'flow
expect(findings).toHaveLength(1);
expect(findings[0].severity).toBe("error");
});
+
+ it("an EMPTY surfaceId is a PENDING step (Phase C): warn 'not built yet', never a dangling error", () => {
+ const flows: Flow[] = [{ id: "flow.flow-1", name: "Outline", steps: [{ id: "step.a", title: "A", surfaceId: "" }] }];
+ const findings = flowLint(flows, ctx());
+ expect(findings).toHaveLength(1);
+ expect(findings[0].gate).toBe("flow");
+ expect(findings[0].severity).toBe("warn");
+ expect(findings[0].code).toBe("pending-step");
+ expect(findings[0].target).toBe("flow.flow-1/step.a");
+ expect(findings[0].message).toMatch(/not built yet/i);
+ });
+
+ it("a pending step never also warns about its advanceOn (one cause, one finding)", () => {
+ const flows: Flow[] = [
+ { id: "flow.flow-1", name: "Outline", steps: [{ id: "step.a", title: "A", surfaceId: "", advanceOn: ["anything"] }] },
+ ];
+ const findings = flowLint(flows, ctx());
+ expect(findings).toHaveLength(1);
+ expect(findings[0].code).toBe("pending-step");
+ expect(findings[0].severity).toBe("warn");
+ });
+
+ it("parseFlow accepts a pending step's empty surfaceId (shape anchor: '' is a state, not malformation)", () => {
+ const pending = { id: "flow.x", name: "X", steps: [{ id: "step.a", title: "A", surfaceId: "" }] };
+ expect(parseFlow(pending)).toEqual(pending);
+ });
});
describe("bindStepSurface — accept-into-step (P4 Phase B)", () => {
diff --git a/e2e/composer-prod-smoke.spec.ts b/e2e/composer-prod-smoke.spec.ts
index 0723b5d..8862f2d 100644
--- a/e2e/composer-prod-smoke.spec.ts
+++ b/e2e/composer-prod-smoke.spec.ts
@@ -366,6 +366,68 @@ test("flows: create, walk, advance, persist, and round-trip through export/impor
await expect(page.locator("[data-project-canvas]")).toContainText(/order/i); // step 2 now renders the re-bound surface
});
+test("build a flow: plan deterministically, edit the plan, create the pending flow, build steps, walk it (P4 Phase C)", async ({ page }) => {
+ await page.goto("/");
+ await newProject(page, "shadcn", "Flow composer");
+
+ // Flow mode is opt-in: the DEFAULT Build render carries no flow-mode
+ // elements — the single-surface path is untouched until the toggle.
+ await expect(page.getByTestId("build-flow-goal")).toHaveCount(0);
+ await expect(page.getByTestId("flow-plan-run")).toHaveCount(0);
+ await expect(page.getByTestId("flow-plan-accept")).toHaveCount(0);
+
+ await page.getByTestId("build-model").selectOption("scripted");
+ await page.getByTestId("build-mode-flow").click();
+ await page
+ .getByTestId("build-flow-goal")
+ .fill("Show one order in full detail. Let people delete their account. Show a table of the remaining accounts.");
+ await page.getByTestId("flow-plan-run").click();
+
+ // Scripted planning is the labeled deterministic outline — and editable.
+ await expect(page.getByTestId("flow-plan-editor")).toBeVisible();
+ await expect(page.getByTestId("flow-plan-source")).toContainText(/deterministic/i);
+ await expect(page.getByTestId("flow-plan-title-2")).toBeVisible(); // 3 sentences → 3 steps
+ await page.getByTestId("flow-plan-remove-2").click();
+ await expect(page.getByTestId("flow-plan-title-2")).toHaveCount(0);
+ await page.getByTestId("flow-plan-title-0").fill("Review the order");
+ await page.getByTestId("flow-plan-intent-0").selectOption("record-detail");
+ await page.getByTestId("flow-plan-title-1").fill("Delete the account");
+ await page.getByTestId("flow-plan-intent-1").selectOption("destructive-action");
+
+ // Create flow & build steps: the flow exists immediately with PENDING
+ // steps, then each step runs as an ORDINARY sequential scripted build.
+ await page.getByTestId("flow-plan-accept").click();
+ await expect(page.getByTestId("build-gate-summary-1")).toContainText("Follows your design-system rules", { timeout: 30_000 });
+ await expect(page.getByTestId("build-gate-summary-2")).toContainText("Follows your design-system rules", { timeout: 30_000 });
+
+ // Accept turn 1 — PRE-TARGETED to its step by the driver (Phase B binding).
+ await page.getByTestId("build-accept-1").click();
+ await expect(page.getByTestId("build-accepted-1")).toContainText(/ex\.chat-\d+/);
+ await expect(page.getByTestId("build-accepted-1")).toContainText("Review the order");
+
+ // Half-built flow: step 2 is visibly PENDING in Preview — an outline state,
+ // never a crash, never a dangling error.
+ await page.getByTestId("nav-preview").click();
+ await page.getByTestId("flow-flow.flow-1").click();
+ await expect(page.getByTestId("flow-step-step.review-the-order")).toHaveClass(/st-btn--active/);
+ await expect(page.getByTestId("flow-step-step.delete-the-account")).toBeDisabled();
+ await expect(page.getByTestId("flow-step-step.delete-the-account")).toContainText(/pending/i);
+ await page.getByTestId("flow-next").click();
+ await expect(page.getByTestId("flow-step-pending")).toBeVisible();
+ await expect(page.getByTestId("flow-step-pending")).toContainText(/not built yet/i);
+
+ // Back in Build, accept turn 2 into ITS step; the flow completes.
+ await page.getByTestId("nav-build").click();
+ await page.getByTestId("build-accept-2").click();
+ await expect(page.getByTestId("build-accepted-2")).toContainText("Delete the account");
+ await page.getByTestId("nav-preview").click();
+ await page.getByTestId("flow-flow.flow-1").click();
+ await expect(page.locator("[data-project-canvas]")).toContainText(/order/i);
+ await page.getByTestId("flow-next").click();
+ await expect(page.getByTestId("flow-step-step.delete-the-account")).toHaveClass(/st-btn--active/);
+ await expect(page.locator("[data-project-canvas]")).toContainText(/delete/i);
+});
+
test("client traffic carries no private hosts, local paths, or key material", async ({ page }) => {
const bodies: string[] = [];
page.on("response", async (r) => {
diff --git a/packages/composer-core/src/flow-plan.test.ts b/packages/composer-core/src/flow-plan.test.ts
new file mode 100644
index 0000000..66a88a4
--- /dev/null
+++ b/packages/composer-core/src/flow-plan.test.ts
@@ -0,0 +1,189 @@
+import { describe, expect, it } from "vitest";
+import { buildFlowPlanRequest, flowPlanDeterministic, flowPlanSchema, reconcileFlowPlan } from "./flow-plan";
+
+/**
+ * P4 Phase C — flow decomposition planning (fail-first).
+ *
+ * "Build a flow" turns ONE workflow goal into an editable plan of 2–8 steps,
+ * each step an ordinary single-surface build (title + generation goal +
+ * governed intent picked from the contract's OWN taxonomy). Same layer and
+ * idioms as plan.ts: a provider-agnostic request tested SHAPE-ONLY (no live
+ * gateway call anywhere), a reconciler that clamps model output to the
+ * contract, and a deterministic fallback that never blocks.
+ */
+
+const contract = {
+ name: "demo/ui",
+ intents: [
+ {
+ id: "structured-input",
+ name: "Structured input",
+ description: "Collects structured values and commits them on an explicit action — a form the user fills and submits.",
+ },
+ {
+ id: "destructive-action",
+ name: "Destructive action",
+ description: "Performs an irreversible or high-consequence operation: deleting records or accounts, revoking access.",
+ },
+ {
+ id: "record-collection",
+ name: "Record collection",
+ description: "Presents many records of the same kind — tickets, orders, members — in a table or list.",
+ },
+ {
+ id: "record-detail",
+ name: "Record detail",
+ description: "Shows one record in full: an order, an account, a project, with its fields and related actions.",
+ },
+ ],
+ components: {
+ button: { whenToUse: "Trigger an action" },
+ input: { whenToUse: "Collect a single-line text value" },
+ card: { whenToUse: "Group related content" },
+ table: { whenToUse: "Present rows of records" },
+ },
+};
+
+const intentIds = contract.intents.map((i) => i.id);
+
+/** A Gateway-#12-style lifecycle goal (the corpus text lives outside this
+ * repo; this fixture mirrors its shape: one workflow, numbered stations). */
+const LIFECYCLE_GOAL = [
+ "The whole request lifecycle for a customer integration:",
+ "1. Browse the catalog of integration packages in a table and pick one.",
+ "2. Fill in an estimate form with quantities and the delivery window.",
+ "3. Review the estimate and confirm the order irreversibly.",
+ "4. Show the created project record with its status fields.",
+].join("\n");
+
+describe("buildFlowPlanRequest — provider-agnostic decomposition request (shape only)", () => {
+ it("carries the goal as the user message and names the design system + governed contexts", () => {
+ const req = buildFlowPlanRequest(LIFECYCLE_GOAL, contract);
+ expect(req.messages).toEqual([{ role: "user", content: LIFECYCLE_GOAL }]);
+ expect(req.system).toContain("demo/ui");
+ for (const id of intentIds) expect(req.system).toContain(id);
+ // The decomposition rules, stated: bounded ordered steps, one screen each,
+ // intents from the listed ids only, no invented steps.
+ expect(req.system).toMatch(/2–8 ordered steps|2-8 ordered steps/);
+ expect(req.system).toContain("ONE screen");
+ expect(req.system).toContain("Do not invent steps");
+ });
+
+ it("schemas the plan strictly: 2–8 steps, closed objects, intent as a REAL-id enum", () => {
+ const schema = buildFlowPlanRequest("a goal", contract).jsonSchema as {
+ additionalProperties?: boolean;
+ required?: string[];
+ properties: { name: unknown; steps: { minItems?: number; maxItems?: number; items: { additionalProperties?: boolean; required?: string[]; properties: { intent?: { enum?: string[] } } } } };
+ };
+ expect(schema.additionalProperties).toBe(false);
+ expect(schema.required).toEqual(["name", "steps"]);
+ expect(schema.properties.steps.minItems).toBe(2);
+ expect(schema.properties.steps.maxItems).toBe(8);
+ const item = schema.properties.steps.items;
+ expect(item.additionalProperties).toBe(false);
+ expect(item.required).toEqual(["title", "goal"]); // intent stays optional; the reconciler fills it
+ expect(item.properties.intent?.enum).toEqual(intentIds);
+ });
+
+ it("flowPlanSchema degrades to a plain string intent when a contract has no intents", () => {
+ const schema = flowPlanSchema([]) as { properties: { steps: { items: { properties: { intent: unknown } } } } };
+ expect(schema.properties.steps.items.properties.intent).toEqual({ type: "string" });
+ });
+});
+
+describe("reconcileFlowPlan — clamp a model plan to the contract", () => {
+ const rawPlan = {
+ name: "Integration request lifecycle",
+ steps: [
+ { title: "Browse the catalog", goal: "a table of integration packages to pick from", intent: "record-collection" },
+ { title: "Create the estimate", goal: "a form with quantities and delivery window", intent: "structured-input" },
+ ],
+ };
+
+ it("keeps a valid plan verbatim, source 'model'", () => {
+ const plan = reconcileFlowPlan(rawPlan, contract, LIFECYCLE_GOAL);
+ expect(plan.source).toBe("model");
+ expect(plan.name).toBe("Integration request lifecycle");
+ expect(plan.steps.map((s) => s.intent)).toEqual(["record-collection", "structured-input"]);
+ expect(plan.steps.map((s) => s.title)).toEqual(["Browse the catalog", "Create the estimate"]);
+ });
+
+ it("an invalid or missing step intent falls back to planDeterministic on THAT STEP's goal", () => {
+ const plan = reconcileFlowPlan(
+ {
+ name: "X",
+ steps: [
+ { title: "A", goal: "permanently delete the account and revoke access", intent: "made-up" },
+ { title: "B", goal: "a table of all support tickets" }, // no intent at all
+ ],
+ },
+ contract,
+ "goal",
+ );
+ expect(plan.steps[0].intent).toBe("destructive-action");
+ expect(plan.steps[1].intent).toBe("record-collection");
+ });
+
+ it("bounds the plan: at most 8 steps, sliced name/title/goal lengths", () => {
+ const steps = Array.from({ length: 12 }, (_, i) => ({
+ title: `Step ${i} ${"t".repeat(200)}`,
+ goal: `${"g".repeat(500)}`,
+ intent: "structured-input",
+ }));
+ const plan = reconcileFlowPlan({ name: "n".repeat(400), steps }, contract, "goal");
+ expect(plan.steps).toHaveLength(8);
+ expect(plan.name.length).toBeLessThanOrEqual(120);
+ for (const s of plan.steps) {
+ expect(s.title.length).toBeLessThanOrEqual(80);
+ expect(s.goal.length).toBeLessThanOrEqual(300);
+ }
+ });
+
+ it("fewer than 2 usable steps (or garbage) falls back to the deterministic outline — never an empty plan", () => {
+ for (const raw of [null, {}, { name: "X", steps: [] }, { name: "X", steps: [{ title: "", goal: " " }] }, { name: "X", steps: "nope" }]) {
+ const plan = reconcileFlowPlan(raw, contract, LIFECYCLE_GOAL);
+ expect(plan.source).toBe("scripted");
+ expect(plan.steps.length).toBeGreaterThanOrEqual(2);
+ for (const s of plan.steps) expect(intentIds).toContain(s.intent);
+ }
+ });
+});
+
+describe("flowPlanDeterministic — the honest outline that never blocks", () => {
+ it("splits a numbered lifecycle goal into its stations (the #12 shape): ≥4 steps, real intents, labeled scripted", () => {
+ const plan = flowPlanDeterministic(LIFECYCLE_GOAL, contract);
+ expect(plan.source).toBe("scripted");
+ expect(plan.reason).toMatch(/deterministic/i);
+ expect(plan.steps.length).toBeGreaterThanOrEqual(4);
+ expect(plan.steps.length).toBeLessThanOrEqual(8);
+ expect(plan.name.length).toBeGreaterThan(0);
+ for (const s of plan.steps) {
+ expect(s.title.length).toBeGreaterThan(0);
+ expect(s.goal.length).toBeGreaterThan(0);
+ expect(intentIds).toContain(s.intent);
+ }
+ // The stations arrive in order: catalog table → estimate form → confirm → project record.
+ expect(plan.steps[0].goal).toMatch(/catalog|packages/i);
+ expect(plan.steps[0].intent).toBe("record-collection");
+ });
+
+ it("clusters a plain multi-sentence goal into one step per sentence", () => {
+ const plan = flowPlanDeterministic(
+ "Show one order in full detail. Let people delete their account. Show a table of remaining accounts.",
+ contract,
+ );
+ expect(plan.steps).toHaveLength(3);
+ expect(plan.steps[1].intent).toBe("destructive-action");
+ });
+
+ it("a single-sentence goal still yields an editable outline of at least 2 steps", () => {
+ const plan = flowPlanDeterministic("let people book a meeting room", contract);
+ expect(plan.steps.length).toBeGreaterThanOrEqual(2);
+ for (const s of plan.steps) expect(intentIds).toContain(s.intent);
+ });
+
+ it("a rambling goal is bounded to 6 outline steps", () => {
+ const rambling = Array.from({ length: 12 }, (_, i) => `Do the ${i}th thing with records.`).join(" ");
+ expect(flowPlanDeterministic(rambling, contract).steps.length).toBeLessThanOrEqual(6);
+ });
+});
diff --git a/packages/composer-core/src/flows.test.ts b/packages/composer-core/src/flows.test.ts
index 4280809..a5360ed 100644
--- a/packages/composer-core/src/flows.test.ts
+++ b/packages/composer-core/src/flows.test.ts
@@ -33,6 +33,15 @@ describe("flowSchema — the zod twin of the app's parseFlow", () => {
}
});
+ it("accepts an EMPTY surfaceId — a PENDING step, planned but not yet built (Phase C anchor)", () => {
+ const outline = { id: "flow.x", name: "X", steps: [{ id: "step.a", title: "A", surfaceId: "" }] };
+ const parsed = flowSchema.safeParse(outline);
+ expect(parsed.success).toBe(true);
+ if (parsed.success) expect(parsed.data).toEqual(outline);
+ const asArray = parseFlows([outline]);
+ expect(asArray.ok).toBe(true);
+ });
+
it("preserves the RESERVED `on` branching annotation without acting on it (F4)", () => {
const withOn = {
id: "flow.x",
From f06b2967c7f1d4571c1339076e6235c3effeb4a6 Mon Sep 17 00:00:00 2001
From: Ryan Dombrowski
Date: Wed, 12 Aug 2026 09:08:29 -0400
Subject: [PATCH 2/2] =?UTF-8?q?feat:=20P4=20Phase=20C=20=E2=80=94=20"Build?=
=?UTF-8?q?=20a=20flow":=20decomposition=20planning,=20pending=20steps,=20?=
=?UTF-8?q?sequential=20step=20driver?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
One workflow goal becomes an EDITABLE plan; every step then builds through
the ordinary governed pipeline — same gates, same repairs, same Accept —
one step at a time. dspack-gen never learns flows exist; the planner is
Composer/composer-core code beside plan.ts, and no test touches a live
gateway (the decomposition request is shape-only, the plan.test.ts rule).
- composer-core flow-plan.ts (new, plan.ts idioms): buildFlowPlanRequest —
system names the design system, lists the contract's intents, states the
2–8 ordered ONE-screen-per-step rules and forbids invented steps; schema
closes every object, bounds steps 2–8, and constrains step intent to a
REAL-id enum (optional; the reconciler fills gaps). reconcileFlowPlan —
clamps each step's intent (invalid/missing -> planDeterministic on THAT
step's goal), bounds name/title/goal with the same slice idiom, bounds to
8 steps, and falls back to the deterministic outline when fewer than 2
usable steps remain; source "model". flowPlanDeterministic — the honest,
never-blocking outline: one step per numbered item (the workflow framing
line is not a step), else per sentence clustered to at most 6, else the
goal plus a review step; per-step intents through the same deterministic
classifier scripted builds use; labeled scripted with a reason that says
it is a deterministic outline.
- Pending steps are first-class: surfaceId "" means PLANNED, NOT YET BUILT.
flowLint warns (pending-step, "not built yet") instead of the dangling
error — one cause, one finding; parseFlow/flowSchema/manifest accept ""
(anchored). Preview renders pending navigator chips visibly disabled
("· pending"), and a pending current step shows a quiet outline state
(flow-step-pending) — a flow of only pending steps previews as its
outline, never a crash.
- Build gains an OPT-IN flow mode (build-mode-flow; the default
single-surface render and behavior are untouched — the e2e pins that no
flow-mode elements exist until the toggle): goal textarea -> "Plan the
flow" (hosted plans via the existing runGatewayRequest seam, any throw ->
the labeled deterministic outline; scripted/local plan deterministically)
-> an editable plan editor (title/goal/intent per step, add/remove/
reorder, Phase A editor idioms) -> "Create flow & build steps": the flow
is created IMMEDIATELY with every step pending through the single
saveFlows funnel, then the driver runs each step as an ordinary runBuild
turn (goal = step.goal, intentOverride = step.intent) STRICTLY
sequentially — never two builds in flight — spaced 8s for model providers
(the P3a burst finding; scripted has no provider to protect and runs
back-to-back). Each result is a normal turn whose Accept is PRE-TARGETED
to its step via the Phase B binding (the header select still wins); a
failing step stops the drive, later steps stay pending, and per-step
"build step" buttons resume or re-run individually.
- runBuild now RESOLVES with the turn's outcome, computed from the turn's
own event stream (never from state) so the driver can sequence and stop;
single-surface callers ignore it. Turns carry an optional flowStepHint;
the accept note names the pre-targeted step.
All suites green: composer-core 77 -> 89, composer 65 -> 68, agent 48
(untouched), typecheck clean, smoke e2e 12 -> 13 (existing specs untouched),
composer-agent e2e 44/44 against the real agent.
Co-Authored-By: Claude Fable 5
---
apps/composer/app/flows.ts | 19 +-
apps/composer/app/planning.ts | 29 +-
apps/composer/app/state.tsx | 37 ++-
apps/composer/app/views/build-view.tsx | 320 +++++++++++++++++++++--
apps/composer/app/views/preview-view.tsx | 14 +-
packages/composer-core/src/flow-plan.ts | 222 ++++++++++++++++
packages/composer-core/src/index.ts | 8 +
7 files changed, 611 insertions(+), 38 deletions(-)
create mode 100644 packages/composer-core/src/flow-plan.ts
diff --git a/apps/composer/app/flows.ts b/apps/composer/app/flows.ts
index aaced33..0139a5c 100644
--- a/apps/composer/app/flows.ts
+++ b/apps/composer/app/flows.ts
@@ -255,6 +255,18 @@ export function missingSurfaceMessage(step: FlowStep): string {
return `step '${step.id}' references surface '${step.surfaceId}', which is not in this project's surfaces`;
}
+/** A PENDING step (Phase C): planned but not yet built — `surfaceId: ""` is a
+ * STATE, never a malformation and never a dangling reference. "Build a
+ * flow" creates whole flows of these, then fills them step by step. */
+export function isPendingStep(step: FlowStep): boolean {
+ return step.surfaceId === "";
+}
+
+/** The pending wording, shared by flow-lint and Preview's outline state. */
+export function pendingStepMessage(step: FlowStep): string {
+ return `step '${step.id}' is not built yet — build it from Build and accept into this step`;
+}
+
export function flowLint(flows: Flow[], ctx: FlowLintContext): ComposerFinding[] {
const findings: ComposerFinding[] = [];
const flowIdsSeen = new Set();
@@ -278,7 +290,12 @@ export function flowLint(flows: Flow[], ctx: FlowLintContext): ComposerFinding[]
}
stepIdsSeen.add(step.id);
- if (!ctx.exampleIds.has(step.surfaceId)) {
+ if (isPendingStep(step)) {
+ // Planned-but-unbuilt is a first-class state (Phase C): a WARN that
+ // names the remaining work, never a dangling error — and one cause,
+ // one finding (no advanceOn check against a surface that isn't there).
+ findings.push(finding("flow", "pending-step", "warn", target, pendingStepMessage(step)));
+ } else if (!ctx.exampleIds.has(step.surfaceId)) {
// One cause, one finding: with no surface there is nothing to check
// advanceOn against, so the reference error stands alone.
findings.push(finding("flow", "dangling-surface", "error", target, missingSurfaceMessage(step)));
diff --git a/apps/composer/app/planning.ts b/apps/composer/app/planning.ts
index 9942cdd..0d0ba5e 100644
--- a/apps/composer/app/planning.ts
+++ b/apps/composer/app/planning.ts
@@ -16,7 +16,16 @@
* Any inference failure falls back to the deterministic classifier so the flow
* always proceeds.
*/
-import { buildPlanRequest, planDeterministic, reconcilePlan, type GoalPlan } from "@dspack-studio/composer-core";
+import {
+ buildFlowPlanRequest,
+ buildPlanRequest,
+ flowPlanDeterministic,
+ planDeterministic,
+ reconcileFlowPlan,
+ reconcilePlan,
+ type FlowPlan,
+ type GoalPlan,
+} from "@dspack-studio/composer-core";
import { runGatewayRequest } from "./hosted-build";
export async function planGoal(goal: string, modelRef: string, contract: Record): Promise {
@@ -33,3 +42,21 @@ export async function planGoal(goal: string, modelRef: string, contract: Record<
// scripted + agent (v1): deterministic routing.
return planDeterministic(goal, contract);
}
+
+/**
+ * Flow decomposition (P4 Phase C): one workflow goal → an editable plan of
+ * ordered steps, through the SAME dispatch shape as planGoal — hosted infers
+ * via the gateway, everything else (and any gateway failure) gets the
+ * clearly-labeled deterministic outline. Planning never blocks the flow.
+ */
+export async function planFlow(goal: string, modelRef: string, contract: Record): Promise {
+ if (modelRef === "hosted-ai") {
+ try {
+ const json = await runGatewayRequest(buildFlowPlanRequest(goal, contract));
+ return reconcileFlowPlan(json, contract, goal);
+ } catch {
+ return flowPlanDeterministic(goal, contract);
+ }
+ }
+ return flowPlanDeterministic(goal, contract);
+}
diff --git a/apps/composer/app/state.tsx b/apps/composer/app/state.tsx
index 3a05a24..0082213 100644
--- a/apps/composer/app/state.tsx
+++ b/apps/composer/app/state.tsx
@@ -115,6 +115,9 @@ export interface BuildTurn {
accepted?: string; // the saved example id
/** The flow step this accept re-bound, by title (P4 Phase B). */
acceptedIntoStep?: string;
+ /** "Build a flow" (Phase C): the step this turn was built FOR. Accept
+ * pre-targets it (the Phase B binding); the header select still wins. */
+ flowStepHint?: { flowId: string; stepId: string; title: string };
/** Structured findings from a refused Accept, rendered in place (#41). */
acceptFindings?: ComposerFinding[];
/** The inferred governed context + feasibility for this turn (goal-first). */
@@ -221,7 +224,17 @@ export interface ComposerState {
configureLocalProvider: (kind: LocalKind, baseUrl: string, model: string) => void;
/** Setup completeness for building; reason names the exact remaining work. */
readiness: BuildReadiness;
- runBuild: (input: { goal: string; modelRef: string; refine?: boolean; intentOverride?: string }) => Promise;
+ /** One build turn. Resolves with the turn's OUTCOME (computed from the
+ * turn's own event stream, never from state) so the Phase C flow driver
+ * can sequence steps and stop on failure; single-surface callers ignore
+ * it. `flowStepHint` tags the turn with the flow step it builds FOR. */
+ runBuild: (input: {
+ goal: string;
+ modelRef: string;
+ refine?: boolean;
+ intentOverride?: string;
+ flowStepHint?: { flowId: string; stepId: string; title: string };
+ }) => Promise<"passed" | "failed" | "vocab-gap" | "not-run">;
/** Accept a turn as a worked example; the agent mints the id (#42). An
* optional flow-step binding re-points that step at the minted id (P4) —
* a STALE binding never fails the accept, it is reported in the notice. */
@@ -1059,23 +1072,29 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
* prior surface; every gate runs again; prior turns stay for audit.
*/
const runBuild = useCallback(
- async (input: { goal: string; modelRef: string; refine?: boolean; intentOverride?: string }) => {
- if (buildBusy) return;
+ async (input: {
+ goal: string;
+ modelRef: string;
+ refine?: boolean;
+ intentOverride?: string;
+ flowStepHint?: { flowId: string; stepId: string; title: string };
+ }): Promise<"passed" | "failed" | "vocab-gap" | "not-run"> => {
+ if (buildBusy) return "not-run";
if (!contract || !profile) {
setNotice("No project loaded yet.");
- return;
+ return "not-run";
}
// A local provider runs through the agent; without it, don't silently
// fall back to a different provider — say so and stop.
if (isLocalRef(input.modelRef) && !agentUp) {
setNotice("This model runs on your machine through the local agent, which isn’t running. Start it (pnpm --filter agent dev), or choose Hosted or Scripted in Settings.");
- return;
+ return "not-run";
}
// Only a completed, passing turn can seed a refinement (#43).
const prior = input.refine ? [...buildTurns].reverse().find((t) => canRefineTurn(t.progress)) : undefined;
if (input.refine && !prior) {
setNotice("Nothing to refine yet — refinement starts from a completed build that passed its gates.");
- return;
+ return "not-run";
}
setBuildBusy(true);
const id = ++turnSeq.current;
@@ -1086,6 +1105,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
modelRef: input.modelRef,
refinement: !!prior,
...(prior ? { parentId: prior.id } : {}),
+ ...(input.flowStepHint ? { flowStepHint: input.flowStepHint } : {}),
progress: { status: "streaming", attempts: [] },
gaps: [],
kind: "surface",
@@ -1114,7 +1134,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
);
buildStream.current = null;
setBuildBusy(false);
- return;
+ return "vocab-gap";
}
// --- GENERATE: the SAME deterministic pipeline, now under the inferred
@@ -1179,6 +1199,9 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
});
buildStream.current = null;
setBuildBusy(false);
+ // The turn's outcome, from ITS OWN event stream (state would be stale
+ // in this closure): the Phase C driver sequences on it.
+ return foldBuildEvents(events).outcome === "passed" ? "passed" : "failed";
},
[mode, projectPath, buildBusy, buildTurns, contract, profile, providerConfig, agentUp],
);
diff --git a/apps/composer/app/views/build-view.tsx b/apps/composer/app/views/build-view.tsx
index 7bb88a1..d123384 100644
--- a/apps/composer/app/views/build-view.tsx
+++ b/apps/composer/app/views/build-view.tsx
@@ -15,8 +15,9 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { A2uiCanvas } from "@dspack-studio/a2ui-ingest";
import { registryFor, canvasScopeFor } from "../registries";
-import { buildFailure, canAcceptTurn, canRefineTurn, intentLabel } from "@dspack-studio/composer-core";
-import type { StepBinding } from "../flows";
+import { buildFailure, canAcceptTurn, canRefineTurn, intentLabel, type FlowPlan } from "@dspack-studio/composer-core";
+import { mintStepId, nextFlowId, type StepBinding } from "../flows";
+import { planFlow } from "../planning";
import type { BuildTurn } from "../state";
import { useComposer } from "../state";
import { Eyebrow } from "../ui";
@@ -24,6 +25,24 @@ import { browserEmit } from "../validation";
const GATE_COLOR: Record = { PASS: "var(--ok)", FAIL: "var(--err)", SKIPPED: "var(--fg-dim)" };
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+/** Spacing between sequential flow-step builds (the P3a burst finding): the
+ * driver NEVER fires two builds concurrently, and model providers get 8s of
+ * air between steps. Scripted is deterministic with zero model calls — no
+ * provider to protect — so it runs back-to-back (still strictly sequential). */
+const stepSpacingMs = (modelRef: string): number => (modelRef === "scripted" ? 0 : 8000);
+
+const field = {
+ fontFamily: "var(--mono)",
+ fontSize: 12,
+ background: "var(--bg-1)",
+ border: "1px solid var(--line)",
+ color: "var(--fg)",
+ padding: "4px 6px",
+ borderRadius: 2,
+} as const;
+
/** Governance in plain language — S1/S2/S3 are implementation codes; a first-time
* user reads outcomes. The deterministic evidence is unchanged; the raw gates,
* rule ids, and rationales stay one expander away (advanced detail). */
@@ -175,6 +194,11 @@ function TurnCanvas({ turn }: { turn: BuildTurn }) {
function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: StepBinding }) {
const { acceptBuildTurn, buildBusy, busy, mode, isExample } = useComposer();
+ // Accept targeting: the header "flow step" select (an explicit choice)
+ // wins; otherwise a "Build a flow" turn is PRE-TARGETED to the step it was
+ // built for (Phase C driver hint). Plain accepts stay plain.
+ const binding =
+ intoFlowStep ?? (turn.flowStepHint ? { flowId: turn.flowStepHint.flowId, stepId: turn.flowStepHint.stepId } : undefined);
// Blank by default: identity is minted from the contract ON DISK, so a
// reload or a second tab can never collide with saved work (#42).
const [exampleId, setExampleId] = useState("");
@@ -331,7 +355,7 @@ function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: Ste
className="st-btn"
disabled={locked}
aria-label={`Accept turn ${turn.id} into the project${exampleId ? ` with id ${exampleId}` : ""}`}
- onClick={() => void acceptBuildTurn(turn.id, exampleId.trim() || undefined, intoFlowStep)}
+ onClick={() => void acceptBuildTurn(turn.id, exampleId.trim() || undefined, binding)}
data-testid={`build-accept-${turn.id}`}
>
Add to project
@@ -343,6 +367,9 @@ function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: Ste
: isExample
? "Kept for this session only — duplicate this example into your projects to keep what you build."
: "Saves to this project in your browser as a worked example — it appears in Preview and Scenarios, and seeds future generation."}
+ {!intoFlowStep && turn.flowStepHint && (
+ <> Accepting also points flow step “{turn.flowStepHint.title}” at this surface.>
+ )}
)}
@@ -358,7 +385,7 @@ function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: Ste
}
export function BuildView() {
- const { mode, agentUp, contract, readiness, buildTurns, buildBusy, buildModels, selectableModels, runBuild, activeModel, setActiveModel, flows } =
+ const { mode, agentUp, contract, readiness, buildTurns, buildBusy, buildModels, selectableModels, runBuild, activeModel, setActiveModel, flows, saveFlows } =
useComposer();
const [prompt, setPrompt] = useState("");
// "" = auto: the governed context is INFERRED from the goal. A specific value
@@ -368,6 +395,15 @@ export function BuildView() {
// that flow step to the minted surface (P4 Phase B). Generation itself is
// untouched — this is an ACCEPT-time affordance on the intent-select pattern.
const [intoStepKey, setIntoStepKey] = useState("");
+ /* ---- "Build a flow" (P4 Phase C) — OPT-IN; the default single-surface
+ path renders and behaves exactly as before until the toggle. ---- */
+ const [buildMode, setBuildMode] = useState<"surface" | "flow">("surface");
+ const [flowGoal, setFlowGoal] = useState("");
+ const [flowPlan, setFlowPlan] = useState(null);
+ const [planBusy, setPlanBusy] = useState(false);
+ // The accepted plan's created flow + its minted step ids (index-aligned
+ // with the frozen plan), and the sequential driver's position.
+ const [flowBuild, setFlowBuild] = useState<{ flowId: string; stepIds: string[]; running: boolean; at: number | null } | null>(null);
const intents = ((contract?.intents ?? []) as Array<{ id: string }>).map((i) => i.id);
const streamStatus = useRef(null);
const canRefine = buildTurns.some((t) => canRefineTurn(t.progress));
@@ -391,6 +427,81 @@ export function BuildView() {
setPrompt("");
};
+ /* ---------------- "Build a flow" planning + sequential driver ---------------- */
+
+ const planTheFlow = async () => {
+ if (!contract || !flowGoal.trim() || planBusy) return;
+ setPlanBusy(true);
+ setFlowBuild(null); // a fresh plan starts a fresh composition session
+ try {
+ setFlowPlan(await planFlow(flowGoal.trim(), activeModel, contract));
+ } finally {
+ setPlanBusy(false);
+ }
+ };
+
+ const patchPlanStep = (at: number, patch: Partial) =>
+ setFlowPlan((p) => (p ? { ...p, steps: p.steps.map((s, i) => (i === at ? { ...s, ...patch } : s)) } : p));
+
+ const movePlanStep = (at: number, delta: -1 | 1) =>
+ setFlowPlan((p) => {
+ if (!p) return p;
+ const to = at + delta;
+ if (to < 0 || to >= p.steps.length) return p;
+ const steps = p.steps.slice();
+ [steps[at], steps[to]] = [steps[to], steps[at]];
+ return { ...p, steps };
+ });
+
+ const removePlanStep = (at: number) => setFlowPlan((p) => (p ? { ...p, steps: p.steps.filter((_, i) => i !== at) } : p));
+
+ const addPlanStep = () =>
+ setFlowPlan((p) => (p ? { ...p, steps: [...p.steps, { title: `Step ${p.steps.length + 1}`, goal: "", intent: intents[0] ?? "" }] } : p));
+
+ /** ONE step's ordinary build, tagged with the step it builds for. Used by
+ * the sequential driver and by the per-step resume/re-run buttons. */
+ const driveStep = async (at: number, flowId: string, stepIds: string[], plan: FlowPlan) => {
+ const step = plan.steps[at];
+ const stepId = stepIds[at];
+ if (!step || !stepId) return "not-run" as const;
+ return runBuild({
+ goal: step.goal.trim() || step.title,
+ modelRef: activeModel,
+ intentOverride: step.intent,
+ flowStepHint: { flowId, stepId, title: step.title },
+ });
+ };
+
+ /**
+ * Create the flow IMMEDIATELY with every step PENDING (it exists, previews
+ * as an outline, exports, lints as pending), then drive the step builds
+ * SEQUENTIALLY — never two in flight, spaced for model providers (the P3a
+ * burst finding). Each result arrives as an ordinary turn with the normal
+ * Accept, pre-targeted to its step; a failure stops the drive and the
+ * remaining steps stay pending (resume per step below).
+ */
+ const acceptPlan = async () => {
+ if (!flowPlan || flowPlan.steps.length === 0 || planBusy || buildBusy || flowBuild?.running) return;
+ const plan = flowPlan; // frozen for this drive; the editor locks while running
+ const flowId = nextFlowId(flows.map((f) => f.id));
+ const taken = new Set();
+ const flowSteps = plan.steps.map((s) => {
+ const stepId = mintStepId(s.title, taken);
+ taken.add(stepId);
+ return { id: stepId, title: s.title, surfaceId: "" };
+ });
+ saveFlows([...flows, { id: flowId, name: plan.name.trim() || "Untitled flow", description: flowGoal.trim().slice(0, 240), steps: flowSteps }]);
+ const stepIds = flowSteps.map((s) => s.id);
+ setFlowBuild({ flowId, stepIds, running: true, at: 0 });
+ for (let i = 0; i < plan.steps.length; i++) {
+ if (i > 0 && stepSpacingMs(activeModel) > 0) await sleep(stepSpacingMs(activeModel));
+ setFlowBuild((fb) => (fb ? { ...fb, at: i } : fb));
+ const outcome = await driveStep(i, flowId, stepIds, plan);
+ if (outcome !== "passed") break; // the failure turn tells the story; later steps stay pending
+ }
+ setFlowBuild((fb) => (fb ? { ...fb, running: false, at: null } : fb));
+ };
+
if (!readiness.ready) {
return (
@@ -410,33 +521,190 @@ export function BuildView() {
Describe what you want, in your own words. Composer works out the governed context, builds it from this
project’s approved components only, checks it in front of you, and renders it in your design system.
-
-
- setPrompt(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && submit(false)}
- placeholder="e.g. a form to invite teammates by email · a confirmation for deleting a project · a table of orders"
- aria-label="Describe what you want to build"
- style={{ flex: 1 }}
- data-testid="build-prompt"
- />
-
+
+ {buildMode === "flow" && (
+
+ one workflow goal → an editable plan → ordinary per-step builds. Nothing new is generated in one shot.
+
+ )}
+ {buildMode === "surface" ? (
+
+ setPrompt(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && submit(false)}
+ placeholder="e.g. a form to invite teammates by email · a confirmation for deleting a project · a table of orders"
+ aria-label="Describe what you want to build"
+ style={{ flex: 1 }}
+ data-testid="build-prompt"
+ />
+
+
+
+ ) : (
+
+
+ Describe the whole journey. Composer proposes an editable outline; every step then builds through the ordinary
+ governed pipeline — same gates, same Accept, one step at a time.
+
+
+
+ {flowPlan && (
+
+
+
+ flow name
+
+ setFlowPlan((p) => (p ? { ...p, name: e.target.value } : p))}
+ disabled={flowBuild?.running === true}
+ aria-label="Flow name"
+ data-testid="flow-plan-name"
+ />
+
+ {flowPlan.source === "scripted"
+ ? flowPlan.reason || "Deterministic outline — no model call."
+ : "Proposed by the model — edit anything before building."}
+
+
+ {flowBuild === null && (
+
+ )}
+
+
+ {flowBuild === null
+ ? `${flowPlan.steps.length} step${flowPlan.steps.length === 1 ? "" : "s"} — builds run one at a time${stepSpacingMs(activeModel) > 0 ? ", spaced 8s" : ""}.`
+ : flowBuild.running
+ ? `building step ${(flowBuild.at ?? 0) + 1} of ${flowBuild.stepIds.length} — sequential, never concurrent…`
+ : "steps ran — accept each turn below (pre-targeted to its step), or re-build a step."}
+
+ {flowBuild === null && (
+
+ )}
+
+
+ )}
+
+ )}
+
+ ) : flowStep && isPendingStep(flowStep) ? (
+ // A planned-but-unbuilt step is an OUTLINE state, not a defect: a
+ // flow of only pending steps previews as its outline this way.
+
+
This step isn’t built yet
+
{pendingStepMessage(flowStep)}.
+
) : flowStep && !active ? (
This step’s surface can’t render
diff --git a/packages/composer-core/src/flow-plan.ts b/packages/composer-core/src/flow-plan.ts
new file mode 100644
index 0000000..0ab3e35
--- /dev/null
+++ b/packages/composer-core/src/flow-plan.ts
@@ -0,0 +1,222 @@
+/**
+ * Flow decomposition planning (P4 Phase C) — "Build a flow".
+ *
+ * One workflow goal becomes an EDITABLE plan of 2–8 ordered steps, each step
+ * an ordinary single-surface build: a short title, a one-sentence generation
+ * goal in the user's domain language, and a governed intent chosen from the
+ * contract's OWN taxonomy. The planner proposes; the person edits and
+ * approves; every step then runs the unchanged deterministic pipeline with
+ * every gate — dspack-gen never learns flows exist.
+ *
+ * Same layer and idioms as plan.ts: a provider-agnostic request (the caller
+ * runs it through whatever adapter it has), a reconciler that clamps model
+ * output to the contract, and a deterministic fallback that never blocks —
+ * clearly labeled, pre-filling one step per list item or sentence cluster.
+ */
+import { planDeterministic } from "./plan";
+
+export interface FlowPlanStep {
+ /** Short human title — becomes the flow step's title. */
+ title: string;
+ /** The step's generation goal, in the user's domain language. */
+ goal: string;
+ /** A REAL contract intent id (the reconciler guarantees it). */
+ intent: string;
+}
+
+export interface FlowPlan {
+ name: string;
+ steps: FlowPlanStep[];
+ /** One line on how this plan came to be (deterministic outlines say so). */
+ reason: string;
+ /** How the plan was produced — for honesty in the UI and receipts. */
+ source: "model" | "scripted";
+}
+
+interface IntentLike {
+ id: string;
+ name?: string;
+ description?: string;
+}
+
+function intentsOf(contract: Record): IntentLike[] {
+ return ((contract.intents as IntentLike[] | undefined) ?? []).filter((i) => i && typeof i.id === "string");
+}
+
+/** The decomposition output schema. Step `intent` is an enum of real intent
+ * ids (optional — the reconciler fills gaps deterministically). */
+export function flowPlanSchema(intentIds: string[]): Record {
+ const intentEnum = intentIds.length ? { enum: intentIds } : { type: "string" };
+ return {
+ type: "object",
+ additionalProperties: false,
+ required: ["name", "steps"],
+ properties: {
+ name: { type: "string", description: "A short name for the whole flow." },
+ steps: {
+ type: "array",
+ minItems: 2,
+ maxItems: 8,
+ items: {
+ type: "object",
+ additionalProperties: false,
+ required: ["title", "goal"],
+ properties: {
+ title: { type: "string", description: "Short step title (a few words)." },
+ goal: { type: "string", description: "One sentence: the screen to build, in the user's domain language." },
+ intent: intentEnum,
+ },
+ },
+ },
+ },
+ };
+}
+
+/**
+ * Build the provider-agnostic decomposition request. The caller runs it
+ * through whatever adapter it has (hosted/local) and passes the result to
+ * reconcileFlowPlan. Tested shape-only — never against a live gateway.
+ */
+export function buildFlowPlanRequest(
+ goal: string,
+ contract: Record,
+): { system: string; messages: Array<{ role: "user"; content: string }>; jsonSchema: Record } {
+ const intents = intentsOf(contract);
+ const intentLines = intents
+ .map((i) => `- ${i.id}${i.name ? ` (${i.name})` : ""}: ${(i.description ?? "").slice(0, 200)}`)
+ .join("\n");
+ const dsName = (contract.name as string | undefined) ?? "this design system";
+
+ const system = [
+ `You are the flow-decomposition step of a governed UI generation system for the "${dsName}" design system.`,
+ "A user describes a WORKFLOW goal — a multi-step experience, not one screen. You do NOT design screens;",
+ "you outline the flow the governed pipeline will build one step at a time.",
+ "",
+ "Governed contexts (pick each step's `intent` from these ids only):",
+ intentLines || "(none)",
+ "",
+ "Rules:",
+ "- Decompose the user's workflow goal into 2–8 ordered steps. Each step is ONE screen a user sees.",
+ "- Give each step a short title, a one-sentence generation goal in the user's domain language, and",
+ " pick each step's intent from the listed ids only.",
+ "- Do not invent steps the goal doesn't ask for.",
+ "Respond with a single JSON object matching the schema.",
+ ].join("\n");
+
+ return {
+ system,
+ messages: [{ role: "user", content: goal }],
+ jsonSchema: flowPlanSchema(intents.map((i) => i.id)),
+ };
+}
+
+/* ------------------------- deterministic outline ------------------------- */
+
+const MAX_OUTLINE_STEPS = 6;
+
+/** Leading list markers ("1. ", "2) ", "- ", "* ", "• "). */
+const ITEM_MARKER = /(?:^|\n)\s*(?:\d+[.)]\s+|[-*•]\s+)/;
+
+/** Numbered/bulleted items, when the goal is written as a list. The chunk
+ * before the first marker is the workflow FRAMING, not a step. */
+function listItems(goal: string): string[] {
+ if (!ITEM_MARKER.test(goal)) return [];
+ const parts = goal
+ .split(new RegExp(ITEM_MARKER.source, "g"))
+ .map((p) => p.trim())
+ .filter(Boolean);
+ const startsWithMarker = /^\s*(?:\d+[.)]\s+|[-*•]\s+)/.test(goal);
+ const items = startsWithMarker ? parts : parts.slice(1);
+ return items.length >= 2 ? items : [];
+}
+
+function sentences(goal: string): string[] {
+ return goal
+ .split(/(?<=[.!?])\s+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+/** Group N chunks into at most `max` clusters of adjacent chunks. */
+function cluster(chunks: string[], max: number): string[] {
+ if (chunks.length <= max) return chunks;
+ const size = Math.ceil(chunks.length / max);
+ const out: string[] = [];
+ for (let i = 0; i < chunks.length; i += size) out.push(chunks.slice(i, i + size).join(" "));
+ return out;
+}
+
+const clean = (s: string): string => s.replace(/^[\s\-*•\d.)]+/, "").replace(/[\s.:;,]+$/, "").trim();
+
+function titleFor(chunk: string, index: number): string {
+ const words = clean(chunk).split(/\s+/).slice(0, 6).join(" ");
+ return (words || `Step ${index + 1}`).slice(0, 60);
+}
+
+function nameFor(goal: string): string {
+ const first = clean(sentences(goal)[0] ?? goal);
+ return (first || "Untitled flow").slice(0, 80);
+}
+
+/**
+ * The honest fallback — a DETERMINISTIC outline, never a model call, never a
+ * block: one step per numbered item, else per sentence (clustered to at most
+ * 6), else the goal plus a review step. Every step's intent routes through
+ * the same deterministic classifier scripted builds use. Clearly labeled so
+ * the UI can say what this is; the plan is editable before anything builds.
+ */
+export function flowPlanDeterministic(goal: string, contract: Record): FlowPlan {
+ const items = listItems(goal);
+ let chunks = items.length >= 2 ? items : cluster(sentences(goal), MAX_OUTLINE_STEPS);
+ if (chunks.length < 2) {
+ const clauses = goal
+ .split(/\s*(?:;|,\s+then\s+|\s+then\s+)\s*/i)
+ .map((c) => c.trim())
+ .filter(Boolean);
+ chunks = clauses.length >= 2 ? cluster(clauses, MAX_OUTLINE_STEPS) : [goal.trim(), `Review the outcome of: ${clean(goal).slice(0, 120)}`];
+ }
+ const steps: FlowPlanStep[] = chunks.slice(0, MAX_OUTLINE_STEPS).map((chunk, i) => ({
+ title: titleFor(chunk, i),
+ goal: clean(chunk).slice(0, 300) || titleFor(chunk, i),
+ intent: planDeterministic(chunk, contract).intent,
+ }));
+ return {
+ name: nameFor(goal),
+ steps,
+ reason: "Deterministic outline — one step per list item or sentence, no model call. Edit the steps before building.",
+ source: "scripted",
+ };
+}
+
+/**
+ * Validate + normalize a model's raw decomposition against the contract.
+ * Clamps every step intent to a real id (invalid/missing → the deterministic
+ * classifier on THAT step's goal), bounds lengths and the step count, and
+ * falls back to the deterministic outline when fewer than 2 usable steps
+ * remain — a plan is never empty and never blocks.
+ */
+export function reconcileFlowPlan(raw: unknown, contract: Record, goal: string): FlowPlan {
+ const ids = new Set(intentsOf(contract).map((i) => i.id));
+ const r = (raw ?? {}) as Record;
+
+ const rawSteps = Array.isArray(r.steps) ? r.steps : [];
+ const steps: FlowPlanStep[] = [];
+ for (const entry of rawSteps) {
+ if (!entry || typeof entry !== "object") continue;
+ const e = entry as Record;
+ const title = typeof e.title === "string" ? e.title.trim() : "";
+ const stepGoal = typeof e.goal === "string" ? e.goal.trim() : "";
+ if (!title && !stepGoal) continue;
+ const basis = stepGoal || title;
+ steps.push({
+ title: (title || basis).slice(0, 80),
+ goal: basis.slice(0, 300),
+ intent: typeof e.intent === "string" && ids.has(e.intent) ? e.intent : planDeterministic(basis, contract).intent,
+ });
+ if (steps.length === 8) break;
+ }
+ if (steps.length < 2) return flowPlanDeterministic(goal, contract);
+
+ const name = typeof r.name === "string" && r.name.trim() ? r.name.trim().slice(0, 120) : nameFor(goal);
+ return { name, steps, reason: "", source: "model" };
+}
diff --git a/packages/composer-core/src/index.ts b/packages/composer-core/src/index.ts
index 2f9e158..2cb32d9 100644
--- a/packages/composer-core/src/index.ts
+++ b/packages/composer-core/src/index.ts
@@ -82,3 +82,11 @@ export {
intentLabel,
type GoalPlan,
} from "./plan";
+export {
+ buildFlowPlanRequest,
+ flowPlanSchema,
+ flowPlanDeterministic,
+ reconcileFlowPlan,
+ type FlowPlan,
+ type FlowPlanStep,
+} from "./flow-plan";