feat(compound-engineering): settings schema, getters, docs (U9)

Add settingsSchema (default session provider/model, enabled stages, sync
reconcile-on-hooks toggle, reconcile cadence hint) aligned across manifest.json
and the runtime manifest, with typed getters. Wire the consumed getters:
orchestrator passes defaultProvider/defaultModelId into sessions and rejects
disabled stages; hooks gate their reconcile drain on reconcileOnHooks. Add the
plugin README and docs/plugins/compound-engineering.md documenting the hub,
interactive sessions, work bridge, and the sync ownership model.

reconcileIntervalMinutes is exposed as an operator cadence hint but not yet
consumed (no host scheduler; reconcile is on-demand by design).
This commit is contained in:
gsxdsm
2026-06-02 19:58:35 -07:00
parent 4dd7509f53
commit f6c8aeeb04
8 changed files with 576 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, PluginSettingSchema> = {
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<string, unknown>, key: string): string | undefined {
const value = settings[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function asBoolean(settings: Record<string, unknown>, key: string, fallback: boolean): boolean {
const value = settings[key];
return typeof value === "boolean" ? value : fallback;
}
function asNumber(settings: Record<string, unknown>, key: string, fallback: number): number {
const value = settings[key];
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function asStringArray(settings: Record<string, unknown>, 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, unknown>): 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, unknown>): 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, unknown>): string[] {
return asStringArray(settings, "enabledStages", listStages().map((s) => s.stageId));
}
/** Whether the reconcile sweep auto-fires after lifecycle hooks. */
export function getReconcileOnHooks(settings: Record<string, unknown>): boolean {
return asBoolean(settings, "reconcileOnHooks", DEFAULT_RECONCILE_ON_HOOKS);
}
/** On-demand reconcile cadence hint in minutes (>= 1). */
export function getReconcileIntervalMinutes(settings: Record<string, unknown>): number {
return Math.max(1, Math.floor(asNumber(settings, "reconcileIntervalMinutes", DEFAULT_RECONCILE_INTERVAL_MINUTES)));
}