Compound Engineering now treats brainstorm output as the requirements-only form of unified plan artifacts. - Point the brainstorm stage at docs/plans while keeping the existing stage and skill identifiers for compatibility. - Surface CE plan frontmatter metadata during artifact discovery and reading. - Harden CE artifact discovery against symlink escapes for conventional artifact paths. - Update docs, tests, and changeset coverage for the unified brainstorm/plan artifact flow. Files changed: .changeset/fn-7103-ce-plan-alias.md | 7 ++ docs/plugins/compound-engineering.md | 21 +++- .../fusion-plugin-compound-engineering/README.md | 36 ++++-- .../orchestrator-interrupt-resume.test.ts | 8 +- .../src/__tests__/session-store.test.ts | 38 ++++++ .../src/__tests__/stage-registry.test.ts | 18 +++ .../src/__tests__/sync.test.ts | 36 +++++- .../src/artifacts/__tests__/discovery.test.ts | 133 +++++++++++++++++++- .../src/artifacts/discovery.ts | 136 +++++++++++++++++++-- .../src/session/stage-registry.ts | 10 +- .../src/sync/reconciler.ts | 2 +- 11 files changed, 413 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-7103 Fusion-Task-Lineage: 284a5c5c-40f7-44b2-8e3e-1de671dde36a Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
244 lines
13 KiB
Markdown
244 lines
13 KiB
Markdown
# 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/`, legacy `docs/brainstorms/`, unified `docs/plans/`,
|
|
`docs/work/`, `docs/debug/`, `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.
|
|
|
|
<!-- FNXC:CompoundEngineering 2026-06-27-00:44: Fusion keeps brainstorm and plan as separate orchestration stage IDs for board/session compatibility, but upstream CE v3.15.0 made their durable artifact one unified plan under docs/plans with readiness frontmatter. docs/brainstorms remains a legacy discovery input only. -->
|
|
|
|
`brainstorm` and `plan` are an **alias pair** at the artifact layer: Fusion keeps
|
|
the separate `brainstorm` / `plan` stage IDs and bundled `ce-brainstorm` /
|
|
`ce-plan` skill IDs for session history, pipeline ordering, and board
|
|
back-compat, while both stages now operate on upstream unified plan artifacts in
|
|
`docs/plans/`. A brainstorm writes a requirements-only plan
|
|
(`artifact_contract: ce-unified-plan/v1`, `artifact_readiness:
|
|
requirements-only`, `product_contract_source: ce-brainstorm`); `ce-plan`
|
|
enriches that same file in place to `artifact_readiness: implementation-ready`.
|
|
The hub still discovers historical `docs/brainstorms/*.md` files as legacy input,
|
|
but new brainstorm output targets `docs/plans/`.
|
|
|
|
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 }`. The default launchable stages are Strategy, Ideate, Brainstorm, Plan,
|
|
Work, and Debug. The Brainstorm registry entry intentionally points at
|
|
`docs/plans/` even though the durable stage ID remains `brainstorm`, matching the
|
|
unified-plan alias described above. 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 (for
|
|
Brainstorm and Plan, the shared unified `docs/plans/` plan path).
|
|
|
|
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. If the server restarts while a session is already `awaiting_input`,
|
|
submitting the pending answer rehydrates the live interactive handle from the
|
|
persisted conversation history before continuing, so old answerable sessions do
|
|
not require a separate resume action.
|
|
|
|
### Multiple sessions
|
|
|
|
Sessions are independent pipeline runs — the store, routes, and orchestrator
|
|
all hold many at once (each with its own live agent handle). The dashboard's
|
|
**Sessions panel** lists every session with its stage, status, and last
|
|
activity; from there you can:
|
|
|
|
- **open** any session and keep working on it (an `awaiting_input` session is
|
|
flagged "needs your input"),
|
|
- **switch** between sessions — the panel stays visible while a flow is open,
|
|
and a session you switch away from keeps running server-side,
|
|
- **resume** an `interrupted`/`error` session from where it stopped,
|
|
- **cancel** an in-flight (`launching`/`active`/`awaiting_input`) session via
|
|
`POST /sessions/:id/cancel`, which stops any live in-process handle, flushes
|
|
live progress into history, and keeps the row as `interrupted` with a
|
|
`Cancelled by user` marker for inspection/resume,
|
|
- **discard** a settled (completed/error/interrupted) session via
|
|
`DELETE /sessions/:id`, which disposes any live handle before deleting the
|
|
row (pipeline-link rows are kept — board-task provenance survives).
|
|
|
|
Cancel and discard are intentionally different: cancel stops work but preserves
|
|
conversation/progress; discard removes the row entirely.
|
|
|
|
The list refreshes on any CE push event and falls back to polling
|
|
`GET /sessions` while any session has a turn in flight.
|
|
|
|
### Live working output, steering, and the Q&A surface
|
|
|
|
Turn execution is **detached**: `POST /sessions`, `/answer`, and `/resume`
|
|
return as soon as the session row reflects the request, with the agent turn
|
|
running in the background. Closing the flow does not cancel the server-side
|
|
agent; use `POST /sessions/:id/cancel` (or the dashboard cancel icon button) to stop
|
|
an in-flight turn while preserving the session as `interrupted`. While it runs:
|
|
|
|
- The engine streams **live progress** through the seam's `onProgress` option
|
|
(thinking/text deltas + tool start/end markers — a host capability any
|
|
plugin can use). The orchestrator accumulates it per session and
|
|
`GET /sessions/:id` attaches it as `liveActivity`, so the flow renders a
|
|
live working pane (pulsing indicator, muted thinking, per-tool ✓/✗ lines).
|
|
- The per-turn timeout is **inactivity-based**: a long but actively-working
|
|
turn is never killed; only a turn with no progress for `turnIntervalMs` is
|
|
interrupted (its working trace is preserved in the transcript).
|
|
- On settle, the working trace is persisted into the conversation history as a
|
|
condensed collapsible "Agent work" block — the transcript keeps the full
|
|
story: opening message, every past question and answer (option ids rendered
|
|
as labels), steering turns, working traces, and completion.
|
|
|
|
**Steering**: alongside any selectable question the user can type free-text
|
|
guidance — attached to their answer as `{value, comment}`, or sent WITHOUT
|
|
answering as `{feedback}`. The stage system prompt instructs the agent to
|
|
treat both as first-class input (incorporate, adjust course, re-ask or
|
|
proceed).
|
|
|
|
### Transport
|
|
|
|
Session updates are **pushed** over the shared `/api/events` SSE stream. The
|
|
orchestrator emits observable events via `ctx.emitEvent` (turn / question /
|
|
completed / error / interrupted, plus throttled mid-turn progress); the host
|
|
forwards them to connected clients as project-scoped `plugin:custom` events,
|
|
and the view subscribes through the `subscribePluginEvents` context capability
|
|
— refetching the session on each event (no raw `EventSource`; no deep
|
|
dashboard import). Client **polling of `GET /sessions/:id` remains as a
|
|
fallback** while a turn is mid-flight, so a missed event still converges.
|
|
Session identity is project-scoped: the `projectId` used at `start` is
|
|
threaded through every later answer/resume/poll so they resolve the same store
|
|
and live handle.
|
|
|
|
## 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. `lastArtifactPath` is carried forward
|
|
when Brainstorm advances to Plan, so the Plan board task continues the same
|
|
requirements-only unified file and later enriches it rather than creating a
|
|
second artifact.
|
|
|
|
**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`, provenance-aware refresh) 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.
|
|
Existing plugin-local installs with no marker or an older upstream marker are
|
|
refreshed from the bundled source so enabled plugins actually run the pinned
|
|
release after an upgrade; installs whose marker already matches are left alone.
|
|
|
|
### Vendored from upstream
|
|
|
|
The bundled `ce-*` skills and `src/agents/ce-*.md` personas are reconciled from
|
|
[`EveryInc/compound-engineering-plugin`](https://github.com/EveryInc/compound-engineering-plugin),
|
|
pinned at [`compound-engineering-v3.15.0`](https://github.com/EveryInc/compound-engineering-plugin/releases/tag/compound-engineering-v3.15.0)
|
|
(commit `2bbdbfb1d4287db95af407808b53266988ada974`; tarball SHA-256
|
|
`fce13e71bd709f8f572bf167c6af3753fc3fde0309c8f878498c78cb391c0b14`, also
|
|
recorded in `src/upstream-provenance.ts`). Refreshes are
|
|
**reconcile-not-overwrite**: pull
|
|
upstream prompt/reference improvements, but preserve Fusion-local adaptations
|
|
such as stage separation, plugin-local install paths, Fusion await-input
|
|
handling, and `fn_spawn_agent` persona dispatch.
|
|
|
|
## 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. |
|
|
| **Disabled Stages** (`disabledStages`) | string[] | `[]` | Explicit opt-out list. Registered stages launch by default, and the orchestrator rejects only IDs listed here. |
|
|
|
|
### 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`,
|
|
`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.
|