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:
5
.changeset/fn-6575-ce-stage-optout.md
Normal file
5
.changeset/fn-6575-ce-stage-optout.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("compound engineering plugin manifest", () => {
|
||||
const expectedKeys = [
|
||||
"defaultProvider",
|
||||
"defaultModelId",
|
||||
"enabledStages",
|
||||
"disabledStages",
|
||||
"reconcileOnHooks",
|
||||
"reconcileIntervalMinutes",
|
||||
].sort();
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -51,6 +51,7 @@ export {
|
||||
settingsSchema,
|
||||
getDefaultProvider,
|
||||
getDefaultModelId,
|
||||
getDisabledStages,
|
||||
getEnabledStages,
|
||||
getReconcileOnHooks,
|
||||
getReconcileIntervalMinutes,
|
||||
|
||||
@@ -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<CeStepResult> {
|
||||
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) {
|
||||
|
||||
@@ -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<string, PluginSettingSchema> = {
|
||||
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, unknown>): 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, 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[] {
|
||||
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. */
|
||||
|
||||
Reference in New Issue
Block a user