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

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

View File

@@ -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",

View File

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

View File

@@ -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);
});

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"] };
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",
);

View File

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

View File

@@ -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) {

View File

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