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
This commit is contained in:
gsxdsm
2026-06-17 08:45:36 -07:00
parent 9cfa6d8176
commit 05fe6e5e0c
10 changed files with 87 additions and 38 deletions

View File

@@ -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.

View File

@@ -123,8 +123,8 @@ Settings render under **Settings → Plugins → Compound Engineering**.
the host default. Consumed by the orchestrator's factory call. the host default. Consumed by the orchestrator's factory call.
- `defaultModelId` (string) — model within the provider; blank uses the host - `defaultModelId` (string) — model within the provider; blank uses the host
default. Consumed by the orchestrator's factory call. default. Consumed by the orchestrator's factory call.
- `enabledStages` (string[], default = full registry) — only these stage IDs may - `disabledStages` (string[], default `[]`) — explicit opt-out list. Registered
be launched; the orchestrator rejects others. stages launch by default; the orchestrator rejects only IDs listed here.
**Sync** **Sync**
- `reconcileOnHooks` (boolean, default `true`) — auto-fire the reconcile sweep - `reconcileOnHooks` (boolean, default `true`) — auto-fire the reconcile sweep

View File

@@ -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 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. | | **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 ### 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. | | **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`, Getters live in `src/settings.ts` (`getDefaultProvider`, `getDefaultModelId`,
`getEnabledStages`, `getReconcileOnHooks`, `getReconcileIntervalMinutes`), each `getDisabledStages`, `getEnabledStages`, `getReconcileOnHooks`,
returning its default when the setting is absent. `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.

View File

@@ -30,13 +30,13 @@
"group": "Sessions", "group": "Sessions",
"defaultValue": "" "defaultValue": ""
}, },
"enabledStages": { "disabledStages": {
"type": "array", "type": "array",
"itemType": "string", "itemType": "string",
"label": "Enabled Stages", "label": "Disabled Stages",
"description": "Stage IDs that may be launched from the Compound Engineering view (for example strategy, ideate, brainstorm, plan, work, debug).", "description": "Stage IDs hidden from launch in the Compound Engineering view. Empty means all registered stages are launchable.",
"group": "Sessions", "group": "Sessions",
"defaultValue": ["strategy", "ideate", "brainstorm", "plan", "work", "debug"] "defaultValue": []
}, },
"reconcileOnHooks": { "reconcileOnHooks": {
"type": "boolean", "type": "boolean",

View File

@@ -89,7 +89,7 @@ describe("compound engineering plugin manifest", () => {
const expectedKeys = [ const expectedKeys = [
"defaultProvider", "defaultProvider",
"defaultModelId", "defaultModelId",
"enabledStages", "disabledStages",
"reconcileOnHooks", "reconcileOnHooks",
"reconcileIntervalMinutes", "reconcileIntervalMinutes",
].sort(); ].sort();

View File

@@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest";
import type { PluginSettingType } from "@fusion/plugin-sdk"; import type { PluginSettingType } from "@fusion/plugin-sdk";
import { listStages } from "../session/stage-registry.js"; import { listStages } from "../session/stage-registry.js";
import { import {
DEFAULT_ENABLED_STAGES, DEFAULT_DISABLED_STAGES,
DEFAULT_MODEL_ID, DEFAULT_MODEL_ID,
DEFAULT_PROVIDER, DEFAULT_PROVIDER,
DEFAULT_RECONCILE_INTERVAL_MINUTES, DEFAULT_RECONCILE_INTERVAL_MINUTES,
DEFAULT_RECONCILE_ON_HOOKS, DEFAULT_RECONCILE_ON_HOOKS,
getDefaultModelId, getDefaultModelId,
getDefaultProvider, getDefaultProvider,
getDisabledStages,
getEnabledStages, getEnabledStages,
getReconcileIntervalMinutes, getReconcileIntervalMinutes,
getReconcileOnHooks, getReconcileOnHooks,
@@ -42,21 +43,21 @@ describe("compound engineering plugin settings schema", () => {
[ [
"defaultModelId", "defaultModelId",
"defaultProvider", "defaultProvider",
"enabledStages", "disabledStages",
"reconcileIntervalMinutes", "reconcileIntervalMinutes",
"reconcileOnHooks", "reconcileOnHooks",
].sort(), ].sort(),
); );
expect(settingsSchema.defaultProvider.group).toBe("Sessions"); expect(settingsSchema.defaultProvider.group).toBe("Sessions");
expect(settingsSchema.defaultModelId.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.reconcileOnHooks.group).toBe("Sync");
expect(settingsSchema.reconcileIntervalMinutes.group).toBe("Sync"); expect(settingsSchema.reconcileIntervalMinutes.group).toBe("Sync");
}); });
it("defaults enabledStages to the full stage registry", () => { it("defaults disabledStages to no explicit opt-outs", () => {
expect(DEFAULT_ENABLED_STAGES).toEqual(listStages().map((s) => s.stageId)); expect(DEFAULT_DISABLED_STAGES).toEqual([]);
expect(settingsSchema.enabledStages.defaultValue).toEqual(DEFAULT_ENABLED_STAGES); expect(settingsSchema.disabledStages.defaultValue).toEqual(DEFAULT_DISABLED_STAGES);
}); });
it("uses documented literal defaults", () => { it("uses documented literal defaults", () => {
@@ -72,9 +73,6 @@ describe("compound engineering plugin settings schema", () => {
expect(DEFAULT_PROVIDER).toBe(""); expect(DEFAULT_PROVIDER).toBe("");
expect(getDefaultModelId(empty)).toBeUndefined(); expect(getDefaultModelId(empty)).toBeUndefined();
expect(DEFAULT_MODEL_ID).toBe(""); 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(getEnabledStages(empty)).toEqual(listStages().map((s) => s.stageId));
expect(getReconcileOnHooks(empty)).toBe(DEFAULT_RECONCILE_ON_HOOKS); expect(getReconcileOnHooks(empty)).toBe(DEFAULT_RECONCILE_ON_HOOKS);
expect(getReconcileIntervalMinutes(empty)).toBe(DEFAULT_RECONCILE_INTERVAL_MINUTES); expect(getReconcileIntervalMinutes(empty)).toBe(DEFAULT_RECONCILE_INTERVAL_MINUTES);
@@ -84,14 +82,15 @@ describe("compound engineering plugin settings schema", () => {
const populated = { const populated = {
defaultProvider: "anthropic", defaultProvider: "anthropic",
defaultModelId: "claude-opus", defaultModelId: "claude-opus",
enabledStages: ["strategy", "plan"], disabledStages: ["plan"],
reconcileOnHooks: false, reconcileOnHooks: false,
reconcileIntervalMinutes: 30, reconcileIntervalMinutes: 30,
} satisfies Record<string, unknown>; } satisfies Record<string, unknown>;
expect(getDefaultProvider(populated)).toBe("anthropic"); expect(getDefaultProvider(populated)).toBe("anthropic");
expect(getDefaultModelId(populated)).toBe("claude-opus"); 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(getReconcileOnHooks(populated)).toBe(false);
expect(getReconcileIntervalMinutes(populated)).toBe(30); expect(getReconcileIntervalMinutes(populated)).toBe(30);
}); });
@@ -102,10 +101,18 @@ describe("compound engineering plugin settings schema", () => {
expect(getReconcileIntervalMinutes({ reconcileIntervalMinutes: 7.9 })).toBe(7); 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", () => { it("falls back to defaults for malformed values", () => {
const liveDefault = listStages().map((s) => s.stageId); const liveDefault = listStages().map((s) => s.stageId);
expect(getEnabledStages({ enabledStages: "not-an-array" })).toEqual(liveDefault); expect(getDisabledStages({ disabledStages: "not-an-array" })).toEqual([]);
expect(getEnabledStages({ enabledStages: [] })).toEqual(liveDefault); expect(getDisabledStages({ disabledStages: [] })).toEqual([]);
expect(getEnabledStages({ disabledStages: "not-an-array" })).toEqual(liveDefault);
expect(getEnabledStages({ disabledStages: [] })).toEqual(liveDefault);
expect(getDefaultProvider({ defaultProvider: " " })).toBeUndefined(); expect(getDefaultProvider({ defaultProvider: " " })).toBeUndefined();
expect(getReconcileOnHooks({ reconcileOnHooks: "yes" })).toBe(DEFAULT_RECONCILE_ON_HOOKS); expect(getReconcileOnHooks({ reconcileOnHooks: "yes" })).toBe(DEFAULT_RECONCILE_ON_HOOKS);
}); });

View File

@@ -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"] }; h.ctx.settings = { enabledStages: ["strategy", "ideate", "brainstorm", "plan", "work"] };
const factory = vi.fn(async () => ({ const factory = vi.fn(async () => ({
session: makeScriptedSession([{ type: "complete", data: { artifact: "# done" } }]), session: makeScriptedSession([{ type: "complete", data: { artifact: "# done" } }]),
@@ -74,6 +74,25 @@ describe("session skill wiring", () => {
turnTimeoutMs: 5000, 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( await expect(orch.start("debug", { openingMessage: "investigate" })).rejects.toThrow(
"CE stage is not enabled: debug", "CE stage is not enabled: debug",
); );

View File

@@ -51,6 +51,7 @@ export {
settingsSchema, settingsSchema,
getDefaultProvider, getDefaultProvider,
getDefaultModelId, getDefaultModelId,
getDisabledStages,
getEnabledStages, getEnabledStages,
getReconcileOnHooks, getReconcileOnHooks,
getReconcileIntervalMinutes, getReconcileIntervalMinutes,

View File

@@ -11,7 +11,7 @@ import type {
import { resolveDefaultInstallTargetRoot } from "../skill-installation.js"; import { resolveDefaultInstallTargetRoot } from "../skill-installation.js";
import { getCePipelineStore, type CePipelineStore } from "../sync/pipeline-store.js"; import { getCePipelineStore, type CePipelineStore } from "../sync/pipeline-store.js";
import { createCeTaskWithLink } from "../sync/ce-task.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 type { CeActivityTurn, CeSession, CeSessionStore } from "./session-store.js";
import { getCeSessionStore } from "./session-store.js"; import { getCeSessionStore } from "./session-store.js";
import { getStage, type CeStageDefinition } from "./stage-registry.js"; import { getStage, type CeStageDefinition } from "./stage-registry.js";
@@ -383,8 +383,11 @@ export class CeOrchestrator {
async start(stageId: string, opts: StartStageOptions): Promise<CeStepResult> { async start(stageId: string, opts: StartStageOptions): Promise<CeStepResult> {
const stage = getStage(stageId); const stage = getStage(stageId);
if (!stage) throw new Error(`Unknown CE stage: ${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}`); throw new Error(`CE stage is not enabled: ${stageId}`);
} }
if (!this.factory) { if (!this.factory) {

View File

@@ -8,7 +8,7 @@ import { listStages } from "./session/stage-registry.js";
* consumption point in the existing plugin code: * consumption point in the existing plugin code:
* - Sessions group → the orchestrator's interactive-session factory call * - Sessions group → the orchestrator's interactive-session factory call
* (`defaultProvider`/`defaultModelId`) and the launch * (`defaultProvider`/`defaultModelId`) and the launch
* guard (`enabledStages`). * guard (`disabledStages`).
* - Sync group → the reconciler trigger surface (auto-drain on hooks + * - Sync group → the reconciler trigger surface (auto-drain on hooks +
* the cadence hint a refresh surface reads). * 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_PROVIDER = "";
export const DEFAULT_MODEL_ID = ""; export const DEFAULT_MODEL_ID = "";
/** Sessions: which pipeline stages are launchable. Defaults to the full registry. */ /** Sessions: stage launch opt-outs. Empty means every registered stage is launchable. */
export const DEFAULT_ENABLED_STAGES: string[] = listStages().map((s) => s.stageId); export const DEFAULT_DISABLED_STAGES: string[] = [];
/** Sync: whether the board→pipeline reconcile sweep auto-fires after lifecycle hooks. */ /** Sync: whether the board→pipeline reconcile sweep auto-fires after lifecycle hooks. */
export const DEFAULT_RECONCILE_ON_HOOKS = true; export const DEFAULT_RECONCILE_ON_HOOKS = true;
@@ -48,13 +48,13 @@ export const settingsSchema: Record<string, PluginSettingSchema> = {
group: "Sessions", group: "Sessions",
defaultValue: DEFAULT_MODEL_ID, defaultValue: DEFAULT_MODEL_ID,
}, },
enabledStages: { disabledStages: {
type: "array", type: "array",
itemType: "string", itemType: "string",
label: "Enabled Stages", label: "Disabled Stages",
description: "Stage IDs that may be launched from the Compound Engineering view (for example strategy, ideate, brainstorm, plan, work).", description: "Stage IDs hidden from launch in the Compound Engineering view. Empty means all registered stages are launchable.",
group: "Sessions", group: "Sessions",
defaultValue: DEFAULT_ENABLED_STAGES, defaultValue: DEFAULT_DISABLED_STAGES,
}, },
reconcileOnHooks: { reconcileOnHooks: {
@@ -112,12 +112,23 @@ export function getDefaultModelId(settings: Record<string, unknown>): string | u
} }
/** /**
* Stage IDs that may be launched. When unset, defaults to the LIVE registry * Stage IDs explicitly disabled by the operator. Malformed or empty values mean
* (re-read here, not the import-time snapshot) so a stage registered at runtime * no opt-outs, so all registered stages remain launchable.
* is launchable by default — disabling is an explicit opt-out, not opt-in. */
export function getDisabledStages(settings: Record<string, unknown>): 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, unknown>): string[] { export function getEnabledStages(settings: Record<string, unknown>): 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. */ /** Whether the reconcile sweep auto-fires after lifecycle hooks. */