From 05fe6e5e0c006ff0060665e80929231cae9f6010 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 17 Jun 2026 08:45:36 -0700 Subject: [PATCH] FN-6575: make CE stage gating opt-out Make Compound Engineering stage launch gating opt-out so registered stages like debug remain launchable by default. - Replace enabledStages settings schema with disabledStages defaults and docs. - Derive launchable stages from the live registry minus explicit disabled opt-outs. - Update orchestrator gating, exports, tests, and plugin docs for the new setting. - Add a patch changeset for the Compound Engineering plugin behavior fix. Files changed: .changeset/fn-6575-ce-stage-optout.md | 5 ++++ docs/plugins/compound-engineering.md | 4 +-- .../fusion-plugin-compound-engineering/README.md | 9 ++++-- .../manifest.json | 8 +++--- .../src/__tests__/manifest.test.ts | 2 +- .../src/__tests__/settings.test.ts | 33 +++++++++++++--------- .../src/__tests__/skill-wiring.test.ts | 21 +++++++++++++- .../src/index.ts | 1 + .../src/session/orchestrator.ts | 9 ++++-- .../src/settings.ts | 33 ++++++++++++++-------- 10 files changed, 87 insertions(+), 38 deletions(-) Fusion-Task-Id: FN-6575 Fusion-Task-Lineage: 903732d7-4ffb-4c8f-ba3e-e517d88bc644 --- .changeset/fn-6575-ce-stage-optout.md | 5 +++ docs/plugins/compound-engineering.md | 4 +-- .../README.md | 9 +++-- .../manifest.json | 8 ++--- .../src/__tests__/manifest.test.ts | 2 +- .../src/__tests__/settings.test.ts | 33 +++++++++++-------- .../src/__tests__/skill-wiring.test.ts | 21 +++++++++++- .../src/index.ts | 1 + .../src/session/orchestrator.ts | 9 +++-- .../src/settings.ts | 33 ++++++++++++------- 10 files changed, 87 insertions(+), 38 deletions(-) create mode 100644 .changeset/fn-6575-ce-stage-optout.md diff --git a/.changeset/fn-6575-ce-stage-optout.md b/.changeset/fn-6575-ce-stage-optout.md new file mode 100644 index 0000000000..930cb4d0f6 --- /dev/null +++ b/.changeset/fn-6575-ce-stage-optout.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Compound Engineering now treats stage launch settings as an explicit `disabledStages` opt-out list so newly bundled stages, including `ce-debug`, remain launchable on existing installs with stale settings snapshots. diff --git a/docs/plugins/compound-engineering.md b/docs/plugins/compound-engineering.md index cf507c8bab..657ec9a2f3 100644 --- a/docs/plugins/compound-engineering.md +++ b/docs/plugins/compound-engineering.md @@ -123,8 +123,8 @@ Settings render under **Settings → Plugins → Compound Engineering**. the host default. Consumed by the orchestrator's factory call. - `defaultModelId` (string) — model within the provider; blank uses the host default. Consumed by the orchestrator's factory call. -- `enabledStages` (string[], default = full registry) — only these stage IDs may - be launched; the orchestrator rejects others. +- `disabledStages` (string[], default `[]`) — explicit opt-out list. Registered + stages launch by default; the orchestrator rejects only IDs listed here. **Sync** - `reconcileOnHooks` (boolean, default `true`) — auto-fire the reconcile sweep diff --git a/plugins/fusion-plugin-compound-engineering/README.md b/plugins/fusion-plugin-compound-engineering/README.md index 96a913ba7b..89c79a5745 100644 --- a/plugins/fusion-plugin-compound-engineering/README.md +++ b/plugins/fusion-plugin-compound-engineering/README.md @@ -190,7 +190,7 @@ grouped as follows. Every setting has a real consumption point in the plugin. |---|---|---|---| | **Default Session Provider** (`defaultProvider`) | string | _(host default)_ | Passed to the interactive-session factory as `defaultProvider`. Blank → host picks. | | **Default Session Model** (`defaultModelId`) | string | _(host default)_ | Passed to the factory as `defaultModelId`. Blank → host picks. | -| **Enabled Stages** (`enabledStages`) | string[] | full registry | Only these stage IDs may be launched; the orchestrator rejects others. | +| **Disabled Stages** (`disabledStages`) | string[] | `[]` | Explicit opt-out list. Registered stages launch by default, and the orchestrator rejects only IDs listed here. | ### Sync @@ -200,5 +200,8 @@ grouped as follows. Every setting has a real consumption point in the plugin. | **Reconcile Cadence (minutes)** (`reconcileIntervalMinutes`) | number | `15` | Cadence hint for an on-demand refresh surface. Not a continuous poll loop. | Getters live in `src/settings.ts` (`getDefaultProvider`, `getDefaultModelId`, -`getEnabledStages`, `getReconcileOnHooks`, `getReconcileIntervalMinutes`), each -returning its default when the setting is absent. +`getDisabledStages`, `getEnabledStages`, `getReconcileOnHooks`, +`getReconcileIntervalMinutes`), each returning its default when the setting is +absent. `getEnabledStages` remains a derived helper for the live registry minus +explicit `disabledStages` opt-outs; stale persisted `enabledStages` snapshots are +ignored. diff --git a/plugins/fusion-plugin-compound-engineering/manifest.json b/plugins/fusion-plugin-compound-engineering/manifest.json index 4f855d3b0f..21b3cc3e6f 100644 --- a/plugins/fusion-plugin-compound-engineering/manifest.json +++ b/plugins/fusion-plugin-compound-engineering/manifest.json @@ -30,13 +30,13 @@ "group": "Sessions", "defaultValue": "" }, - "enabledStages": { + "disabledStages": { "type": "array", "itemType": "string", - "label": "Enabled Stages", - "description": "Stage IDs that may be launched from the Compound Engineering view (for example strategy, ideate, brainstorm, plan, work, debug).", + "label": "Disabled Stages", + "description": "Stage IDs hidden from launch in the Compound Engineering view. Empty means all registered stages are launchable.", "group": "Sessions", - "defaultValue": ["strategy", "ideate", "brainstorm", "plan", "work", "debug"] + "defaultValue": [] }, "reconcileOnHooks": { "type": "boolean", diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/manifest.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/manifest.test.ts index 70cca7745d..f4bc64c6b5 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/manifest.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/manifest.test.ts @@ -89,7 +89,7 @@ describe("compound engineering plugin manifest", () => { const expectedKeys = [ "defaultProvider", "defaultModelId", - "enabledStages", + "disabledStages", "reconcileOnHooks", "reconcileIntervalMinutes", ].sort(); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/settings.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/settings.test.ts index 5dc51f68ff..8f2cba5a58 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/settings.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/settings.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest"; import type { PluginSettingType } from "@fusion/plugin-sdk"; import { listStages } from "../session/stage-registry.js"; import { - DEFAULT_ENABLED_STAGES, + DEFAULT_DISABLED_STAGES, DEFAULT_MODEL_ID, DEFAULT_PROVIDER, DEFAULT_RECONCILE_INTERVAL_MINUTES, DEFAULT_RECONCILE_ON_HOOKS, getDefaultModelId, getDefaultProvider, + getDisabledStages, getEnabledStages, getReconcileIntervalMinutes, getReconcileOnHooks, @@ -42,21 +43,21 @@ describe("compound engineering plugin settings schema", () => { [ "defaultModelId", "defaultProvider", - "enabledStages", + "disabledStages", "reconcileIntervalMinutes", "reconcileOnHooks", ].sort(), ); expect(settingsSchema.defaultProvider.group).toBe("Sessions"); expect(settingsSchema.defaultModelId.group).toBe("Sessions"); - expect(settingsSchema.enabledStages.group).toBe("Sessions"); + expect(settingsSchema.disabledStages.group).toBe("Sessions"); expect(settingsSchema.reconcileOnHooks.group).toBe("Sync"); expect(settingsSchema.reconcileIntervalMinutes.group).toBe("Sync"); }); - it("defaults enabledStages to the full stage registry", () => { - expect(DEFAULT_ENABLED_STAGES).toEqual(listStages().map((s) => s.stageId)); - expect(settingsSchema.enabledStages.defaultValue).toEqual(DEFAULT_ENABLED_STAGES); + it("defaults disabledStages to no explicit opt-outs", () => { + expect(DEFAULT_DISABLED_STAGES).toEqual([]); + expect(settingsSchema.disabledStages.defaultValue).toEqual(DEFAULT_DISABLED_STAGES); }); it("uses documented literal defaults", () => { @@ -72,9 +73,6 @@ describe("compound engineering plugin settings schema", () => { expect(DEFAULT_PROVIDER).toBe(""); expect(getDefaultModelId(empty)).toBeUndefined(); expect(DEFAULT_MODEL_ID).toBe(""); - // getEnabledStages re-reads the LIVE registry default (so runtime-registered - // stages are launchable); DEFAULT_ENABLED_STAGES is the import-time snapshot - // used for the schema/manifest literal. expect(getEnabledStages(empty)).toEqual(listStages().map((s) => s.stageId)); expect(getReconcileOnHooks(empty)).toBe(DEFAULT_RECONCILE_ON_HOOKS); expect(getReconcileIntervalMinutes(empty)).toBe(DEFAULT_RECONCILE_INTERVAL_MINUTES); @@ -84,14 +82,15 @@ describe("compound engineering plugin settings schema", () => { const populated = { defaultProvider: "anthropic", defaultModelId: "claude-opus", - enabledStages: ["strategy", "plan"], + disabledStages: ["plan"], reconcileOnHooks: false, reconcileIntervalMinutes: 30, } satisfies Record; expect(getDefaultProvider(populated)).toBe("anthropic"); expect(getDefaultModelId(populated)).toBe("claude-opus"); - expect(getEnabledStages(populated)).toEqual(["strategy", "plan"]); + expect(getDisabledStages(populated)).toEqual(["plan"]); + expect(getEnabledStages(populated)).toEqual(listStages().map((s) => s.stageId).filter((id) => id !== "plan")); expect(getReconcileOnHooks(populated)).toBe(false); expect(getReconcileIntervalMinutes(populated)).toBe(30); }); @@ -102,10 +101,18 @@ describe("compound engineering plugin settings schema", () => { expect(getReconcileIntervalMinutes({ reconcileIntervalMinutes: 7.9 })).toBe(7); }); + it("ignores stale enabledStages snapshots when deriving launchable stages", () => { + expect(getEnabledStages({ enabledStages: ["strategy", "ideate", "brainstorm", "plan", "work"] })).toEqual( + listStages().map((s) => s.stageId), + ); + }); + it("falls back to defaults for malformed values", () => { const liveDefault = listStages().map((s) => s.stageId); - expect(getEnabledStages({ enabledStages: "not-an-array" })).toEqual(liveDefault); - expect(getEnabledStages({ enabledStages: [] })).toEqual(liveDefault); + expect(getDisabledStages({ disabledStages: "not-an-array" })).toEqual([]); + expect(getDisabledStages({ disabledStages: [] })).toEqual([]); + expect(getEnabledStages({ disabledStages: "not-an-array" })).toEqual(liveDefault); + expect(getEnabledStages({ disabledStages: [] })).toEqual(liveDefault); expect(getDefaultProvider({ defaultProvider: " " })).toBeUndefined(); expect(getReconcileOnHooks({ reconcileOnHooks: "yes" })).toBe(DEFAULT_RECONCILE_ON_HOOKS); }); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-wiring.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-wiring.test.ts index 72353ca1f5..6a09caf658 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-wiring.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-wiring.test.ts @@ -62,7 +62,7 @@ describe("session skill wiring", () => { }, ); - it("rejects debug launch cleanly when the stage is disabled", async () => { + it("ignores stale enabledStages snapshots so registered stages remain launchable", async () => { h.ctx.settings = { enabledStages: ["strategy", "ideate", "brainstorm", "plan", "work"] }; const factory = vi.fn(async () => ({ session: makeScriptedSession([{ type: "complete", data: { artifact: "# done" } }]), @@ -74,6 +74,25 @@ describe("session skill wiring", () => { turnTimeoutMs: 5000, }); + for (const stageId of ["strategy", "work", "debug"]) { + await orch.start(stageId, { openingMessage: `launch ${stageId}` }); + } + + expect(factory).toHaveBeenCalledTimes(3); + }); + + it("rejects debug launch cleanly when the stage is disabled", async () => { + h.ctx.settings = { disabledStages: ["debug"] }; + const factory = vi.fn(async () => ({ + session: makeScriptedSession([{ type: "complete", data: { artifact: "# done" } }]), + })); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + await expect(orch.start("debug", { openingMessage: "investigate" })).rejects.toThrow( "CE stage is not enabled: debug", ); diff --git a/plugins/fusion-plugin-compound-engineering/src/index.ts b/plugins/fusion-plugin-compound-engineering/src/index.ts index 877f0441b7..4b698b85f9 100644 --- a/plugins/fusion-plugin-compound-engineering/src/index.ts +++ b/plugins/fusion-plugin-compound-engineering/src/index.ts @@ -51,6 +51,7 @@ export { settingsSchema, getDefaultProvider, getDefaultModelId, + getDisabledStages, getEnabledStages, getReconcileOnHooks, getReconcileIntervalMinutes, diff --git a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts index 229423f3bb..d53f8fed40 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts @@ -11,7 +11,7 @@ import type { import { resolveDefaultInstallTargetRoot } from "../skill-installation.js"; import { getCePipelineStore, type CePipelineStore } from "../sync/pipeline-store.js"; import { createCeTaskWithLink } from "../sync/ce-task.js"; -import { getDefaultModelId, getDefaultProvider, getEnabledStages } from "../settings.js"; +import { getDefaultModelId, getDefaultProvider, getDisabledStages } from "../settings.js"; import type { CeActivityTurn, CeSession, CeSessionStore } from "./session-store.js"; import { getCeSessionStore } from "./session-store.js"; import { getStage, type CeStageDefinition } from "./stage-registry.js"; @@ -383,8 +383,11 @@ export class CeOrchestrator { async start(stageId: string, opts: StartStageOptions): Promise { const stage = getStage(stageId); if (!stage) throw new Error(`Unknown CE stage: ${stageId}`); - // Setting-gated launch (U9): only stages the operator enabled may launch. - if (!getEnabledStages(this.ctx.settings).includes(stageId)) { + /* + * FNXC:CompoundEngineering 2026-06-17-08:09: + * Stage launch gating is opt-out: a registered CE stage launches unless operators explicitly list it in disabledStages. This keeps existing installs from rejecting newly appended stages because of stale enabledStages snapshots. + */ + if (getDisabledStages(this.ctx.settings).includes(stageId)) { throw new Error(`CE stage is not enabled: ${stageId}`); } if (!this.factory) { diff --git a/plugins/fusion-plugin-compound-engineering/src/settings.ts b/plugins/fusion-plugin-compound-engineering/src/settings.ts index c22d40bdab..1db3aec5d8 100644 --- a/plugins/fusion-plugin-compound-engineering/src/settings.ts +++ b/plugins/fusion-plugin-compound-engineering/src/settings.ts @@ -8,7 +8,7 @@ import { listStages } from "./session/stage-registry.js"; * consumption point in the existing plugin code: * - Sessions group → the orchestrator's interactive-session factory call * (`defaultProvider`/`defaultModelId`) and the launch - * guard (`enabledStages`). + * guard (`disabledStages`). * - Sync group → the reconciler trigger surface (auto-drain on hooks + * the cadence hint a refresh surface reads). * @@ -20,8 +20,8 @@ import { listStages } from "./session/stage-registry.js"; export const DEFAULT_PROVIDER = ""; export const DEFAULT_MODEL_ID = ""; -/** Sessions: which pipeline stages are launchable. Defaults to the full registry. */ -export const DEFAULT_ENABLED_STAGES: string[] = listStages().map((s) => s.stageId); +/** Sessions: stage launch opt-outs. Empty means every registered stage is launchable. */ +export const DEFAULT_DISABLED_STAGES: string[] = []; /** Sync: whether the board→pipeline reconcile sweep auto-fires after lifecycle hooks. */ export const DEFAULT_RECONCILE_ON_HOOKS = true; @@ -48,13 +48,13 @@ export const settingsSchema: Record = { group: "Sessions", defaultValue: DEFAULT_MODEL_ID, }, - enabledStages: { + disabledStages: { type: "array", itemType: "string", - label: "Enabled Stages", - description: "Stage IDs that may be launched from the Compound Engineering view (for example strategy, ideate, brainstorm, plan, work).", + label: "Disabled Stages", + description: "Stage IDs hidden from launch in the Compound Engineering view. Empty means all registered stages are launchable.", group: "Sessions", - defaultValue: DEFAULT_ENABLED_STAGES, + defaultValue: DEFAULT_DISABLED_STAGES, }, reconcileOnHooks: { @@ -112,12 +112,23 @@ export function getDefaultModelId(settings: Record): string | u } /** - * Stage IDs that may be launched. When unset, defaults to the LIVE registry - * (re-read here, not the import-time snapshot) so a stage registered at runtime - * is launchable by default — disabling is an explicit opt-out, not opt-in. + * Stage IDs explicitly disabled by the operator. Malformed or empty values mean + * no opt-outs, so all registered stages remain launchable. + */ +export function getDisabledStages(settings: Record): string[] { + return asStringArray(settings, "disabledStages", DEFAULT_DISABLED_STAGES); +} + +/** + * Stage IDs that may be launched. This is the LIVE registry minus explicit + * disabled-stage opt-outs; stale persisted `enabledStages` snapshots are ignored. + * + * FNXC:CompoundEngineering 2026-06-17-08:06: + * The previous enabledStages allow-list was snapshotted into plugin settings at first install, so later appended stages such as debug were silently un-launchable on existing installs. Use disabledStages as an explicit opt-out so every registered stage is launchable by default as documented. */ export function getEnabledStages(settings: Record): string[] { - return asStringArray(settings, "enabledStages", listStages().map((s) => s.stageId)); + const disabled = new Set(getDisabledStages(settings)); + return listStages().map((s) => s.stageId).filter((stageId) => !disabled.has(stageId)); } /** Whether the reconcile sweep auto-fires after lifecycle hooks. */