diff --git a/docs/plugins/compound-engineering.md b/docs/plugins/compound-engineering.md new file mode 100644 index 0000000000..32b2de78db --- /dev/null +++ b/docs/plugins/compound-engineering.md @@ -0,0 +1,99 @@ +# Compound Engineering Plugin + +A dedicated dashboard surface for the compound-engineering (CE) workflow — an +artifact hub, interactive `ce-*` skill sessions, a work→board bridge, and +event-driven bidirectional sync. It runs alongside Fusion's native pipeline. + +## Install + +1. Open **Settings → Plugins → Fusion Plugins**. +2. In **Bundled Plugins**, click **Install** for **Compound Engineering**. +3. Enable the plugin if it is not already started. + +When installed and enabled, the plugin registers the **Compound Engineering** +dashboard view destination and installs its bundled `ce-*` skills into a +plugin-local, discoverable directory (never a global `~/.claude/skills` path). + +## Dashboard view + +The Compound Engineering view is registered as a primary plugin destination +(`viewId: "compound-engineering"`). + +It provides: +- An **artifact hub** that discovers CE artifacts from conventional locations + (`STRATEGY.md`, `docs/ideation/`, `docs/brainstorms/`, plan docs, `docs/work/`, + `CONCEPTS.md`, `docs/solutions/`) grouped by stage, with explicit + empty / partial / error states. +- Self-contained artifact previews read through plugin routes under + `/api/plugins/fusion-plugin-compound-engineering/`. +- A **stage launcher** listing the registered, operator-enabled stages. + +## Sessions + +Each stage maps to a bundled skill via the stage registry +(`{ stageId, skillId, artifactLocation, icon, label }`). Launching a stage starts +an interactive agent session on the host's `createInteractiveAiSession` seam. + +The orchestrator streams `thinking`/`text` turns, surfaces a structured +`question` (pausing in `awaiting_input`), accepts a structured answer, and on +`complete` writes the artifact to the stage's conventional location. Lifecycle: +`launching → active → awaiting_input → completed`, plus `error` and +`interrupted`. Interrupt/error auto-saves progress and emits an observable event; +sessions resume/retry back to their current question. + +Transport is polling (`GET /sessions/:id`) — plugin routes have no native +server-push and no raw `EventSource` is used. + +HTTP endpoints (under `/api/plugins/fusion-plugin-compound-engineering/`): +- `POST /sessions` → start a stage session +- `POST /sessions/:id/answer` → answer the awaiting question +- `POST /sessions/:id/resume` → resume an awaiting/interrupted session +- `GET /sessions/:id` → current persisted session state (polling) +- `GET /sessions` → list sessions (filter by status/stage) +- `GET /sessions/:id/links` → the work→board pipeline-link records for a session + +## Sync model + +Two separate state machines are kept in sync, never merged: + +- **Board-task ownership** → the task `column`. The **board is authoritative for + task state**. +- **CE-pipeline ownership** → `ce_pipeline_state.{currentStage, status}`. The + **CE flow is authoritative for artifact/pipeline content**. + +**Inbound:** `onTaskMoved` / `onTaskCompleted` hooks resolve the link and enqueue +a sync signal under the 5s hook budget — no inline advancement. + +**Reconcile:** `reconcileCePipelines(ctx)` is a single on-demand sweep (not a +poll loop). It drains the queue and independently re-derives transitions from +live board state, so a dropped or never-enqueued event still converges. + +**Outbound:** when a pipeline advances to a stage that produces board work, the +reconciler creates the next-stage board task and links it. + +**Conflict policy:** the reconciler only reads already-terminal board columns and +only writes CE-owned fields plus a new board task, so the two writers never +contend over the same cell. + +The work bridge tags every CE-originated board task (source `workflow_step` with +CE markers in `sourceMetadata`) and records an authoritative pipeline-link row; +created tasks then run the normal lifecycle untouched. + +## Settings + +Settings render under **Settings → Plugins → Compound Engineering**. + +**Sessions** +- `defaultProvider` (string) — provider for CE interactive sessions; blank uses + 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. + +**Sync** +- `reconcileOnHooks` (boolean, default `true`) — auto-fire the reconcile sweep + after task move/complete hooks. When off, the hook still enqueues so an + on-demand sweep converges later. +- `reconcileIntervalMinutes` (number, default `15`) — cadence hint for an + on-demand refresh surface; not a continuous poll loop. diff --git a/plugins/fusion-plugin-compound-engineering/README.md b/plugins/fusion-plugin-compound-engineering/README.md new file mode 100644 index 0000000000..9fb913dcf9 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/README.md @@ -0,0 +1,142 @@ +# Compound Engineering Plugin for Fusion + +A dedicated dashboard surface for the compound-engineering (CE) workflow: an +artifact hub, interactive in-dashboard `ce-*` skill sessions, a work→board +bridge, and event-driven bidirectional sync between the Fusion board and a +plugin-local CE-pipeline state model. It runs **alongside** Fusion's native +pipeline — it does not replace or bypass it. + +## Install (one-click) + +1. Open **Settings → Plugins → Fusion Plugins**. +2. In **Bundled Plugins**, click **Install** on **Compound Engineering**. +3. Enable the plugin if prompted. + +Once installed and enabled, Fusion registers the **Compound Engineering** +dashboard destination automatically and installs the bundled `ce-*` skills into a +plugin-local, discoverable directory. + +## What it does + +Compound engineering normally runs as terminal slash-commands whose artifacts +scatter across `docs/`, with no unified surface and no link between a finished +plan and the board work that follows. This plugin surfaces the whole flow inside +Fusion while **reusing the real skills** so the plugin improves as they do. + +## Artifact hub + +The primary dashboard view (`viewId: "compound-engineering"`) discovers and +renders CE artifacts from their conventional locations (`STRATEGY.md`, +`docs/ideation/`, `docs/brainstorms/`, plan docs, `docs/work/`, `CONCEPTS.md`, +`docs/solutions/`) and groups them by stage. Artifacts are read through a plugin +route and rendered self-contained (sandboxed preview). The hub renders explicit +empty / partial / error states rather than crashing or silently dropping an +unreadable artifact. + +Artifact HTTP endpoints live under +`/api/plugins/fusion-plugin-compound-engineering/` and back the hub list/read. + +## Interactive `ce-*` sessions + +Each pipeline stage maps to a bundled skill via the **stage registry** +(`src/session/stage-registry.ts`): `{ stageId, skillId, artifactLocation, icon, +label }`. Adding a stage is a data entry — no new route, store, or screen. + +The launcher lists the registered (and operator-enabled) stages. Launching a +stage starts an **interactive** agent session driven by the host's +`createInteractiveAiSession` seam (a foundational extension added by this plan, +because the existing `createAiSession` is one-shot and cannot pause on a +mid-agent question). The session orchestrator (`src/session/orchestrator.ts`): + +- streams `thinking` / `text` turns, +- surfaces a structured `question` and pauses in `awaiting_input`, +- accepts a structured answer and continues, +- on `complete`, writes the artifact to the stage's conventional location. + +Lifecycle states are `launching → active → awaiting_input → completed`, plus +`error` and `interrupted`. On interrupt or error the orchestrator **auto-saves +progress and emits an observable event — never silent loss** — and an +`interrupted`/`error` session can be resumed/retried back to its current +question. + +### Transport + +Plugin routes return `{ status, body }` with no native server-push and the loader +`emitEvent` is a logging stub, so v1 uses **polling**: clients poll +`GET /sessions/:id` for the current persisted state. No raw `EventSource` is used. +The orchestrator still emits observable events via `ctx.emitEvent` for the +no-silent-loss requirement; turning those into true client push needs a host +event-publish seam (a documented carry-forward). + +## Work → board bridge + +When a stage reaches its work phase (`ce-work`, stage id `work`), its `complete` +payload may carry a derived task list. The orchestrator creates each as a Fusion +board task via `ctx.taskStore.createTask`, tagged CE-originated (source +`workflow_step` with CE markers in `sourceMetadata`) and recorded as a +**pipeline-link** row. The link row — not task-row JSON — is the authoritative +back-reference from a board task to its originating pipeline/stage/artifact +(per the FN-5719 pattern). Created tasks then run the **normal** lifecycle with +no plugin interference. Zero derived tasks is a clean no-op. + +## Bidirectional sync model + +Two **separate** state machines are kept in sync, never merged: + +- **Board-task ownership** → the task's `column`. **The board is authoritative + for task state.** +- **CE-pipeline ownership** → `ce_pipeline_state.{currentStage, status}`. **The + CE flow is authoritative for artifact/pipeline content.** + +**Inbound (board → pipeline).** The `onTaskMoved` / `onTaskCompleted` lifecycle +hooks do the minimum under the 5s hook budget: resolve the link and +`enqueueSync(...)`, then return. Heavy advancement is **not** done inline. + +**Reconcile (the convergence guarantee).** `reconcileCePipelines(ctx)` is a +single on-demand sweep — **not** a tight interval poll. It (1) drains the queue +and (2) independently re-derives transitions by comparing live board state +against pipeline state. Step (2) is why a dropped or never-enqueued hook event +still converges: the queue is an optimization; the board↔state comparison is the +source of truth. + +**Outbound (pipeline → board).** When a pipeline advances to a stage that +produces board work, the reconciler creates the next-stage board task via +`ctx.taskStore.createTask` and links it. + +**Conflict policy.** The reconciler only reads the already-terminal board task +columns (board-authoritative) and only writes CE-owned fields plus a brand-new +board task — the two writers never contend over the same cell. + +## Bundled-skills isolation model + +The `ce-*` skills are **bundled and pinned** inside the plugin +(`src/skills//SKILL.md`), declared via `PluginSkillContribution` with +plugin-root-relative `skillFiles`. On load they are physically installed +(`cpSync`, idempotent skip-if-exists) into a **plugin-local, discoverable** +directory so an agent session can resolve them. The install is guarded to **never +touch a global `~/.claude/skills` path** an operator's own compound-engineering +install owns — registering the bundled copy can never clobber a global install. + +## Settings + +Operator-facing settings render in **Settings → Plugins → Compound Engineering**, +grouped as follows. Every setting has a real consumption point in the plugin. + +### Sessions + +| Setting | Type | Default | Effect | +|---|---|---|---| +| **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. | + +### Sync + +| Setting | Type | Default | Effect | +|---|---|---|---| +| **Reconcile on Board Changes** (`reconcileOnHooks`) | boolean | `true` | When on, the reconcile sweep auto-fires after task move/complete hooks. When off, the hook still enqueues so an on-demand sweep converges later. | +| **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. diff --git a/plugins/fusion-plugin-compound-engineering/manifest.json b/plugins/fusion-plugin-compound-engineering/manifest.json index dc53db3c6b..c84cf13210 100644 --- a/plugins/fusion-plugin-compound-engineering/manifest.json +++ b/plugins/fusion-plugin-compound-engineering/manifest.json @@ -14,5 +14,43 @@ "placement": "primary", "order": 36 } - ] + ], + "settingsSchema": { + "defaultProvider": { + "type": "string", + "label": "Default Session Provider", + "description": "Model provider used for CE interactive sessions (for example anthropic). Leave blank to use the host default.", + "group": "Sessions", + "defaultValue": "" + }, + "defaultModelId": { + "type": "string", + "label": "Default Session Model", + "description": "Model ID within the provider used for CE interactive sessions. Leave blank to use the host default.", + "group": "Sessions", + "defaultValue": "" + }, + "enabledStages": { + "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).", + "group": "Sessions", + "defaultValue": ["strategy", "ideate", "brainstorm", "plan", "work"] + }, + "reconcileOnHooks": { + "type": "boolean", + "label": "Reconcile on Board Changes", + "description": "Run the board→pipeline reconcile sweep automatically after task move/complete hooks. Disable to only reconcile on demand.", + "group": "Sync", + "defaultValue": true + }, + "reconcileIntervalMinutes": { + "type": "number", + "label": "Reconcile Cadence (minutes)", + "description": "Cadence hint for how often an on-demand refresh surface sweeps the reconciler. Not a continuous poll loop.", + "group": "Sync", + "defaultValue": 15 + } + } } 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 bca6b5765b..19c6a87890 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/manifest.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/manifest.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import manifest from "../../manifest.json"; import plugin from "../index.js"; import { COMPOUND_ENGINEERING_SKILLS } from "../skills.js"; +import { settingsSchema } from "../settings.js"; describe("compound engineering plugin manifest", () => { it("exports expected plugin id", () => { @@ -73,4 +74,22 @@ describe("compound engineering plugin manifest", () => { it("registers an onLoad hook that installs bundled skills (U2)", () => { expect(typeof plugin.hooks?.onLoad).toBe("function"); }); + + it("wires the settings schema onto the runtime manifest and manifest.json (U9)", () => { + const expectedKeys = [ + "defaultProvider", + "defaultModelId", + "enabledStages", + "reconcileOnHooks", + "reconcileIntervalMinutes", + ].sort(); + expect(plugin.manifest.settingsSchema).toBe(settingsSchema); + expect(Object.keys(settingsSchema).sort()).toEqual(expectedKeys); + // manifest.json mirrors the same keys (runtime/JSON alignment). + expect(Object.keys(manifest.settingsSchema).sort()).toEqual(expectedKeys); + // Spot-check one entry stays aligned between JSON and runtime. + expect(manifest.settingsSchema.reconcileIntervalMinutes.defaultValue).toBe( + settingsSchema.reconcileIntervalMinutes.defaultValue, + ); + }); }); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/settings.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/settings.test.ts new file mode 100644 index 0000000000..5dc51f68ff --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/settings.test.ts @@ -0,0 +1,112 @@ +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_MODEL_ID, + DEFAULT_PROVIDER, + DEFAULT_RECONCILE_INTERVAL_MINUTES, + DEFAULT_RECONCILE_ON_HOOKS, + getDefaultModelId, + getDefaultProvider, + getEnabledStages, + getReconcileIntervalMinutes, + getReconcileOnHooks, + settingsSchema, +} from "../settings.js"; + +const VALID_TYPES: PluginSettingType[] = ["string", "number", "boolean", "enum", "password", "array"]; + +describe("compound engineering plugin settings schema", () => { + it("uses only valid plugin setting types and labels", () => { + for (const [key, schema] of Object.entries(settingsSchema)) { + expect(VALID_TYPES).toContain(schema.type); + expect(typeof schema.label).toBe("string"); + expect(schema.label?.trim().length).toBeGreaterThan(0); + + if (schema.type === "enum") { + expect(Array.isArray(schema.enumValues)).toBe(true); + expect(schema.enumValues?.length ?? 0).toBeGreaterThan(0); + } + + if (schema.type === "array") { + expect(schema.itemType).toBe("string"); + } + + expect(key.length).toBeGreaterThan(0); + } + }); + + it("exposes the expected keys grouped into Sessions and Sync", () => { + expect(Object.keys(settingsSchema).sort()).toEqual( + [ + "defaultModelId", + "defaultProvider", + "enabledStages", + "reconcileIntervalMinutes", + "reconcileOnHooks", + ].sort(), + ); + expect(settingsSchema.defaultProvider.group).toBe("Sessions"); + expect(settingsSchema.defaultModelId.group).toBe("Sessions"); + expect(settingsSchema.enabledStages.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("uses documented literal defaults", () => { + expect(settingsSchema.reconcileOnHooks.defaultValue).toBe(true); + expect(settingsSchema.reconcileIntervalMinutes.defaultValue).toBe(15); + expect(settingsSchema.defaultProvider.defaultValue).toBe(""); + expect(settingsSchema.defaultModelId.defaultValue).toBe(""); + }); + + it("returns defaults for empty settings", () => { + const empty = {}; + expect(getDefaultProvider(empty)).toBeUndefined(); + 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); + }); + + it("returns configured values when provided", () => { + const populated = { + defaultProvider: "anthropic", + defaultModelId: "claude-opus", + enabledStages: ["strategy", "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(getReconcileOnHooks(populated)).toBe(false); + expect(getReconcileIntervalMinutes(populated)).toBe(30); + }); + + it("clamps the reconcile cadence to at least one minute", () => { + expect(getReconcileIntervalMinutes({ reconcileIntervalMinutes: 0 })).toBe(1); + expect(getReconcileIntervalMinutes({ reconcileIntervalMinutes: -5 })).toBe(1); + expect(getReconcileIntervalMinutes({ reconcileIntervalMinutes: 7.9 })).toBe(7); + }); + + 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(getDefaultProvider({ defaultProvider: " " })).toBeUndefined(); + expect(getReconcileOnHooks({ reconcileOnHooks: "yes" })).toBe(DEFAULT_RECONCILE_ON_HOOKS); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/index.ts b/plugins/fusion-plugin-compound-engineering/src/index.ts index 4788aa0d91..6be65a3c10 100644 --- a/plugins/fusion-plugin-compound-engineering/src/index.ts +++ b/plugins/fusion-plugin-compound-engineering/src/index.ts @@ -6,6 +6,8 @@ import { createSessionRoutes } from "./routes/session-routes.js"; import { createArtifactRoutes } from "./routes/artifact-routes.js"; import { getCePipelineStore } from "./sync/pipeline-store.js"; import { reconcileCePipelines } from "./sync/reconciler.js"; +import { settingsSchema } from "./settings.js"; +import { getReconcileOnHooks } from "./settings.js"; export { CompoundEngineeringDashboardView } from "./dashboard-view.js"; export { COMPOUND_ENGINEERING_SKILLS } from "./skills.js"; @@ -35,6 +37,14 @@ export { CE_WORK_SOURCE_TYPE, } from "./session/orchestrator.js"; export { getStage, listStages, registerStage } from "./session/stage-registry.js"; +export { + settingsSchema, + getDefaultProvider, + getDefaultModelId, + getEnabledStages, + getReconcileOnHooks, + getReconcileIntervalMinutes, +} from "./settings.js"; const plugin = definePlugin({ manifest: { @@ -45,6 +55,7 @@ const plugin = definePlugin({ author: "Fusion Team", fusionVersion: ">=0.1.0", skills: COMPOUND_ENGINEERING_SKILLS.map((s) => ({ skillId: s.skillId, name: s.name })), + settingsSchema, }, state: "installed", skills: COMPOUND_ENGINEERING_SKILLS, @@ -70,6 +81,10 @@ const plugin = definePlugin({ fromColumn, toColumn, }); + // Setting-gated auto-drain (U9): when disabled, the enqueue still happens + // so an on-demand reconcile (route/refresh) converges later; we just skip + // the inline sweep. + if (!getReconcileOnHooks(ctx.settings)) return; void Promise.resolve() .then(() => reconcileCePipelines(ctx)) .catch((err) => ctx.logger.warn(`CE reconcile (onTaskMoved) failed: ${String(err)}`)); @@ -84,6 +99,7 @@ const plugin = definePlugin({ reason: "task_completed", toColumn: "done", }); + if (!getReconcileOnHooks(ctx.settings)) return; void Promise.resolve() .then(() => reconcileCePipelines(ctx)) .catch((err) => ctx.logger.warn(`CE reconcile (onTaskCompleted) failed: ${String(err)}`)); diff --git a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts index efc7d4c7a6..66547d2dd3 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts @@ -9,6 +9,7 @@ import type { } from "@fusion/core"; import { resolveDefaultInstallTargetRoot } from "../skill-installation.js"; import { getCePipelineStore, type CePipelineStore } from "../sync/pipeline-store.js"; +import { getDefaultModelId, getDefaultProvider, getEnabledStages } from "../settings.js"; import type { CeSession, CeSessionStore } from "./session-store.js"; import { getCeSessionStore } from "./session-store.js"; import { getStage, type CeStageDefinition } from "./stage-registry.js"; @@ -185,6 +186,10 @@ 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)) { + throw new Error(`CE stage is not enabled: ${stageId}`); + } if (!this.factory) { throw new Error( "Interactive AI sessions are not available (createInteractiveAiSession is only injected on route contexts with the engine loaded).", @@ -201,9 +206,21 @@ export class CeOrchestrator { const cwd = resolveStageSkillCwd(stage, this.projectRoot); const systemPrompt = buildStageSystemPrompt(stage); + // Setting-gated model selection (U9): pass the operator's default + // provider/model through to the host factory; omitted keys let the host + // pick its own defaults. + const defaultProvider = getDefaultProvider(this.ctx.settings); + const defaultModelId = getDefaultModelId(this.ctx.settings); + let interactive; try { - interactive = await this.factory({ cwd, systemPrompt, tools: "coding" }); + interactive = await this.factory({ + cwd, + systemPrompt, + tools: "coding", + ...(defaultProvider ? { defaultProvider } : {}), + ...(defaultModelId ? { defaultModelId } : {}), + }); } catch (err) { return { session: this.failSession(session.id, err), event: undefined }; } diff --git a/plugins/fusion-plugin-compound-engineering/src/settings.ts b/plugins/fusion-plugin-compound-engineering/src/settings.ts new file mode 100644 index 0000000000..c22d40bdab --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/settings.ts @@ -0,0 +1,131 @@ +import type { PluginSettingSchema } from "@fusion/plugin-sdk"; +import { listStages } from "./session/stage-registry.js"; + +/** + * Operator-facing settings for the Compound Engineering plugin (U9). + * + * Grouped like `fusion-plugin-reports`. Every setting here has a real, honest + * consumption point in the existing plugin code: + * - Sessions group → the orchestrator's interactive-session factory call + * (`defaultProvider`/`defaultModelId`) and the launch + * guard (`enabledStages`). + * - Sync group → the reconciler trigger surface (auto-drain on hooks + + * the cadence hint a refresh surface reads). + * + * `DEFAULT_*` consts are the single source of truth shared by the schema + * defaults, the typed getters, and the settings test. + */ + +/** Sessions: default provider/model for CE interactive sessions. */ +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); + +/** Sync: whether the board→pipeline reconcile sweep auto-fires after lifecycle hooks. */ +export const DEFAULT_RECONCILE_ON_HOOKS = true; +/** + * Sync: cadence hint (minutes) a refresh/poll-fallback surface uses when it + * sweeps the reconciler on demand. This is a HINT, not a host scheduler — there + * is no continuous poll loop (per docs/performance/dashboard-load.md); a refresh + * surface reads this to decide how often to offer/auto-trigger a manual sweep. + */ +export const DEFAULT_RECONCILE_INTERVAL_MINUTES = 15; + +export const settingsSchema: Record = { + defaultProvider: { + type: "string", + label: "Default Session Provider", + description: "Model provider used for CE interactive sessions (for example anthropic). Leave blank to use the host default.", + group: "Sessions", + defaultValue: DEFAULT_PROVIDER, + }, + defaultModelId: { + type: "string", + label: "Default Session Model", + description: "Model ID within the provider used for CE interactive sessions. Leave blank to use the host default.", + group: "Sessions", + defaultValue: DEFAULT_MODEL_ID, + }, + enabledStages: { + 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).", + group: "Sessions", + defaultValue: DEFAULT_ENABLED_STAGES, + }, + + reconcileOnHooks: { + type: "boolean", + label: "Reconcile on Board Changes", + description: "Run the board→pipeline reconcile sweep automatically after task move/complete hooks. Disable to only reconcile on demand.", + group: "Sync", + defaultValue: DEFAULT_RECONCILE_ON_HOOKS, + }, + reconcileIntervalMinutes: { + type: "number", + label: "Reconcile Cadence (minutes)", + description: "Cadence hint for how often an on-demand refresh surface sweeps the reconciler. Not a continuous poll loop.", + group: "Sync", + defaultValue: DEFAULT_RECONCILE_INTERVAL_MINUTES, + }, +}; + +function asString(settings: Record, key: string): string | undefined { + const value = settings[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function asBoolean(settings: Record, key: string, fallback: boolean): boolean { + const value = settings[key]; + return typeof value === "boolean" ? value : fallback; +} + +function asNumber(settings: Record, key: string, fallback: number): number { + const value = settings[key]; + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function asStringArray(settings: Record, key: string, fallback: string[]): string[] { + const value = settings[key]; + if (!Array.isArray(value)) return [...fallback]; + const normalized = value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean); + return normalized.length > 0 ? normalized : [...fallback]; +} + +/** + * Default provider for CE sessions. Returns `undefined` when unset so the + * orchestrator can omit it and let the host pick its default provider. + */ +export function getDefaultProvider(settings: Record): string | undefined { + return asString(settings, "defaultProvider"); +} + +/** + * Default model ID for CE sessions. Returns `undefined` when unset so the + * orchestrator can omit it and let the host pick its default model. + */ +export function getDefaultModelId(settings: Record): string | undefined { + return asString(settings, "defaultModelId"); +} + +/** + * 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. + */ +export function getEnabledStages(settings: Record): string[] { + return asStringArray(settings, "enabledStages", listStages().map((s) => s.stageId)); +} + +/** Whether the reconcile sweep auto-fires after lifecycle hooks. */ +export function getReconcileOnHooks(settings: Record): boolean { + return asBoolean(settings, "reconcileOnHooks", DEFAULT_RECONCILE_ON_HOOKS); +} + +/** On-demand reconcile cadence hint in minutes (>= 1). */ +export function getReconcileIntervalMinutes(settings: Record): number { + return Math.max(1, Math.floor(asNumber(settings, "reconcileIntervalMinutes", DEFAULT_RECONCILE_INTERVAL_MINUTES))); +}