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:
99
docs/plugins/compound-engineering.md
Normal file
99
docs/plugins/compound-engineering.md
Normal file
@@ -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.
|
||||
142
plugins/fusion-plugin-compound-engineering/README.md
Normal file
142
plugins/fusion-plugin-compound-engineering/README.md
Normal 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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}`));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
131
plugins/fusion-plugin-compound-engineering/src/settings.ts
Normal file
131
plugins/fusion-plugin-compound-engineering/src/settings.ts
Normal 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)));
|
||||
}
|
||||
Reference in New Issue
Block a user