Merge pull request #1696 from Runfusion/gsxdsm/ce-workflow-skill-loading

fix: make the compound-engineering workflow actually load skills and run the full CE flow
This commit is contained in:
gsxdsm
2026-06-21 02:04:17 -07:00
committed by GitHub
12 changed files with 1279 additions and 50 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Make the compound-engineering built-in workflow actually load skills and run the full CE flow. Previously the workflow named CE skills at each node but the graph-node execution path (`runGraphCustomNode`) never loaded them: the named skill was only injected as prompt text, the plugin-injected `FUSION_CE_*` runtime env never reached the step session, and `fn_spawn_agent` was never registered for workflow steps, so persona fan-out and skill loading silently no-op'd. Now skill-executor graph steps thread the injected env, load the named skill (discovery + selection via `additionalSkillPaths`), register the spawn tool in coding mode, and receive an engine-injected Fusion workflow-step conventions preamble (await-input for questions, `FUSION_HEADLESS` degrade path, persona fan-out via `systemPromptOverride`). Adds an explicit `unattended` opt-in for `FUSION_HEADLESS`, reconciles the preamble with the gate verdict-JSON contract, and carries `skillName` through the `WorkflowStep` round-trip.

View File

@@ -0,0 +1,332 @@
---
title: "fix: Make the compound-engineering workflow actually load skills and run the full CE flow"
type: fix
status: active
date: 2026-06-20
plan_depth: deep
origin: docs/plans/2026-06-13-002-feat-compound-engineering-workflow-integration-plan.md
---
# fix: Make the compound-engineering workflow actually load skills and run the full CE flow
## Summary
The `builtin:compound-engineering` workflow *looks* fully wired — every node names the right CE skill (`ce-plan`, `ce-work`, `ce-code-review`, `ce-commit-push-pr`, `ce-resolve-pr-feedback`, `ce-compound`), the engine ships the enabling primitives (`systemPromptOverride` on `fn_spawn_agent`, `FUSION_WORKFLOW_STEP=1`, `===FUSION_AWAIT_INPUT===` sentinel parsing, `FUSION_CE_AGENTS_DIR`), and the dashboard has a working "Answer questions" card button + resume banner. Most of the 2026-06-13 integration plan shipped.
But a deep dive into the live execution path shows the workflow does **not** actually deliver compound engineering on a board run. Four load-bearing gaps remain:
1. **The named skill is never loaded into the step session (critical).** `executeWorkflowStep` builds skill selection from the assigned agent / role fallback (`executor → fusion`) only — it never forwards the step's `skillName` as a `requestedSkillName`, and never adds the CE install dir to the session's skill-discovery paths. The skill name is injected only as prompt *text* ("Invoke the `compound-engineering:ce-work` skill…") pointing at a skill the session cannot load. The June-3 skill-loading fix was applied to the interactive path, not the workflow-step path.
2. **9 of 11 bundled CE skills are verbatim upstream (critical).** Only `ce-debug` and `ce-resolve-pr-feedback` were adapted for Fusion. The rest — including `ce-plan`, `ce-work`, `ce-code-review`, `ce-commit-push-pr`, `ce-compound` — still call `AskUserQuestion` (no listener → questions lost) and fan out via raw `Task ce-*(...)` with no `systemPromptOverride` / `FUSION_CE_AGENTS_DIR` wiring, so the `ce-*` personas never resolve.
3. **Fan-out / writing steps have the wrong tool mode (high).** `plan` and `code-review` run readonly, which strips `fn_spawn_agent` — so `ce-plan`'s research agents and `ce-code-review`'s reviewer panel cannot spawn at all. `document` runs readonly, so `ce-compound` cannot write to `docs/solutions`.
4. **No genuinely-unattended signal (medium).** `FUSION_HEADLESS` is reserved but never set, so an LFG/pipeline run with no human would park on `awaiting-user-input` forever instead of recording assumptions and proceeding.
5. **The real execution seam is missing the plumbing entirely (critical, root cause).** The built-in workflow runs through the **graph-node path** (`runGraphCustomNode`, `executor.ts:5998`), which synthesizes an ephemeral step at `~6230` and calls `executeWorkflowStep` with `nodeEnv = undefined` for skill nodes (the injected `FUSION_CE_*` runtime env is only threaded on the *legacy* caller at `:12116`). And `fn_spawn_agent` is registered **only** in the main executor session (`:8076`), never in `executeWorkflowStep` — so coding mode grants write/edit but **not** spawn in any workflow step. Without fixing this seam, the skill-loading and fan-out fixes below silently no-op on the real workflow.
This plan closes all five so the workflow genuinely loads and runs the CE skills end-to-end. The foundational fix is the graph-node seam (U8); the rest build on it. Per the scope decision, the fix targets **all 11 bundled skills** via an **engine-injected conventions preamble** (skills stay byte-for-byte upstream; one maintenance point) — even though only 6 are currently workflow nodes (see U5).
**Target repo:** this repo (kb / Fusion). All paths repo-relative.
---
## Problem Frame
The CE skills were authored for an *interactive* Claude Code session: a human answers blocking questions, and the `Agent`/`Task` primitive resolves a rich `ce-*` registry. Fusion runs them in the opposite environment — an autonomous, ephemeral, readonly-by-default workflow-step session with no human attached and no `ce-*` registry. The 2026-06-13 plan added the *primitives* to bridge that gap (`systemPromptOverride`, the await-input sentinel, the bundled persona defs + `FUSION_CE_AGENTS_DIR`, coding tool mode), and the dashboard pause/resume surface. What never landed is the *wiring that makes the primitives fire*:
- The step session never **loads** the skill it names (so the model improvises from a one-line instruction instead of following the skill).
- The skills never **use** the primitives (they still call `AskUserQuestion` and raw `Task ce-*`), because no per-skill adaptation or engine preamble tells them to.
- Three of the steps that must spawn or write are locked **readonly**.
- There is no signal to **degrade honestly** when truly nobody can answer.
The result is a workflow that name-drops compound engineering at each node but executes a degraded, skill-less version of it. The fix has three threads — **load the skill**, **teach every skill the Fusion conventions once (engine preamble)**, and **give each node the capability (tool mode / headless signal) its skill needs** — plus the end-to-end test that would have caught the loading gap.
---
## Requirements
- R1. For every `executor: "skill"` workflow step, the named skill is actually **loaded** into the step session (discovered on a skill path AND selected by name), not merely referenced in the prompt text.
- R2. Every CE skill step receives the Fusion workflow-step conventions — emit `===FUSION_AWAIT_INPUT===` for user questions instead of `AskUserQuestion`; fan out to a `ce-*` persona by reading its def from `FUSION_CE_AGENTS_DIR` and passing the body as `systemPromptOverride` to `fn_spawn_agent` — **without** forking each skill's markdown.
- R3. A genuinely-unattended run (LFG / pipeline / `disable-model-invocation`) sets `FUSION_HEADLESS=1`; skills then record assumptions and proceed instead of parking. A normal board run (human reachable asynchronously) does **not** set it and still pauses for answers via the existing await-input + card-button surface.
- R4. CE steps whose skills fan out can spawn subagents (`ce-plan`, `ce-code-review`), and the `document` step can write to `docs/solutions` (`ce-compound`).
- R5. All 11 bundled skills behave correctly under the preamble: the two already hand-adapted skills (`ce-debug`, `ce-resolve-pr-feedback`) do not double-instruct, and `ce-resolve-pr-feedback`'s resolver fan-out resolves a persona via `systemPromptOverride`.
- R6. An end-to-end test exercises an `executor: "skill"` step **on the graph-node path** and asserts skill loading, coding-mode spawn availability, preamble presence, and the await-input pause; all existing `builtin-workflows` and executor tests stay green.
- R7. On the graph-node execution path (`runGraphCustomNode`), a skill step's session receives the injected CE runtime env (`FUSION_CE_SKILLS_DIR`, `FUSION_CE_AGENTS_DIR`) and — in coding mode — the `fn_spawn_agent` tool, so R1/R2/R3 actually fire on the workflow the plan targets (not only on the legacy `executeWorkflowStep` caller).
---
## Key Technical Decisions
- KTD-1 — **Load the step skill via `requestedSkillNames` + `additionalSkillPaths`, mirroring the interactive fix.** The resolver already works when fed both (proven by `packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts`); the gap is that `executeWorkflowStep` (`packages/engine/src/executor.ts:12409`, with the skill-context build at `~12532` and `createResolvedAgentSession` at `~12564`) never feeds them. Carry the node's `skillName` onto the `WorkflowStep` and, in the step session, merge it into the resolved `skillSelection`'s requested names and pass the CE skills install root as `additionalSkillPaths`, read from the step env (`FUSION_CE_SKILLS_DIR`, which the plugin exposes via `executorRuntimeEnv`). **Critical ordering:** on the graph-node path that the built-in workflow actually runs, that env key is absent until U8 threads the injected runtime env into `runGraphCustomNode` — so KTD-1 depends on U8, and U6 must assert the dir is present at runtime on the *graph* path, not on a hand-fed `stepEnv`. *Rationale:* the resolver and the discovery seam (`pi.ts` `additionalSkillPaths`) already exist — once the env is threaded (U8), this is the one missing hand-off, and it is exactly the "workflow execution loads skills" requirement.
- KTD-2 — **Engine-injected Fusion-conventions preamble, not per-skill markdown forks.** At the skill-prompt build path (`executor.ts` ~6172, the `executorKind === "skill"` branch) prepend a stable conventions block to the step prompt whenever a skill step runs in a workflow step. The block teaches any skill: (a) you are a Fusion workflow step — `AskUserQuestion` has no listener; surface user questions via a single `===FUSION_AWAIT_INPUT===` block and stop; (b) to fan out to a `ce-<persona>`, read `$FUSION_CE_AGENTS_DIR/<persona>.md`, strip frontmatter, and pass the body as `systemPromptOverride` to `fn_spawn_agent`; when spawn is unavailable, do the work inline. *Rationale:* the chosen approach — skills stay byte-for-byte upstream (trivial re-vendoring on CE updates), and the conventions live in one place that applies to all 11 skills at once. *Idempotency:* `ce-debug` (JSON protocol) and `ce-resolve-pr-feedback` (already emit the sentinel) must not double-instruct — see KTD-5 / U5. *Output-contract coexistence:* `executeWorkflowStep` already injects a system prompt requiring a trailing `{"verdict":...}` JSON object (`executor.ts:12453-12484`); the preamble's "emit a sentinel and stop" / skill-native output must reconcile with that — see KTD-6. *Override correctness:* the preamble must reliably win over 9 unforked skill bodies whose own text still says "call `AskUserQuestion`"; this is asserted behaviorally for at least one skill in U5/U6, not assumed.
- KTD-3 — **`FUSION_HEADLESS` is an explicit opt-in flag threaded through the run, not an inferred origin heuristic.** "Headless" is *not* "running in a workflow step" — a board step is unattended *now* but a human can answer later via the card button. `FUSION_HEADLESS=1` means *no human will ever answer* — LFG / pipeline / `disable-model-invocation` runs. **No such origin signal exists in the executor path today** (verified: no `lfg`/`pipeline`/`disableModelInvocation`/`unattended`/`runOrigin` marker reaches the workflow-step walk — only the existing `FUSION_HEADLESS` reservation comment at `executor.ts:12552`). So this is a threading change, not a lookup: add an explicit `unattended` boolean on the workflow-run options, set it only at the LFG/pipeline/`disable-model-invocation` entrypoints, and thread it through `runGraphCustomNode` → `executeWorkflowStep` → the `stepEnv` build at `executor.ts:12553` (the same threading seam as U8). **Default is unset = board run** — absence of the explicit flag must *always* yield no `FUSION_HEADLESS`, regardless of any other run attribute, so a misdetection can only ever park a task a human can answer (safe direction), never silently skip approval. The preamble's question branch checks `FUSION_HEADLESS`: set → record assumptions and proceed; unset → emit the await-input block. *Rationale:* satisfies "degrade honestly" (R3) without an inference that could regress the board's human-in-the-loop pause.
- KTD-4 — **Bump tool mode on the fan-out / writing nodes — but coding mode alone does not deliver spawn.** `plan` and `code-review` move to `toolMode: "coding"`, and `document` moves to `coding` so `ce-compound` can write `docs/solutions`. **Correction (verified):** coding mode gives write/edit but **not** `fn_spawn_agent` in a workflow step — that tool is registered only in the main executor session (`executor.ts:8076`), never in `executeWorkflowStep`, which passes an empty custom-tool set in coding mode (`:12554-12556, :12581`). The persona fan-out therefore cannot fire in *any* CE step (including the already-coding `execute`/`commit-pr`/`resolve-feedback`) until U8 registers the spawn tool for coding-mode skill steps. So coding mode is necessary but not sufficient; KTD-4 pairs with U8. *Write-capability posture (decision):* coding mode exposes write/edit on `plan`/`code-review`, which those skills are not supposed to use. We accept this for now with **no engine-level guard** — the only protection is skill discipline plus a U6 detection assertion (non-empty diff on plan/code-review is flagged, not blocked). The proper guard, a dedicated readonly-plus-spawn tool mode (spawn without write), stays **deferred** (see Scope Boundaries); the residual risk is documented in Risk-1. *Rationale:* the tool policy is binary today (`workflow-step-tool-policy.ts`), so coding is the only mode that can carry write or (post-U8) spawn.
- KTD-5 — **Persona fan-out stays skill-driven through the preamble + the shipped `systemPromptOverride` primitive.** No new engine surface for personas: the preamble instructs the skill to read the def and pass it as `systemPromptOverride` (the primitive added in the prior plan, `executor.ts` ~958 / ~15217). For `ce-resolve-pr-feedback`, whose await-input branch is already in-place, keep its bespoke question handling but let the preamble supply the persona-fan-out convention for its `ce-pr-comment-resolver` spawn (R5). *Rationale:* reuse the existing primitive; avoid a persona-registry API (still deferred).
- KTD-6 — **Reconcile the preamble with the step's existing verdict-JSON output contract.** `executeWorkflowStep` injects a system prompt requiring every step agent to end with a single trailing `{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE",...}` JSON object (`executor.ts:12453-12484`). That collides with the preamble telling a skill to "emit a `===FUSION_AWAIT_INPUT===` block and stop" and with non-gate skills (`ce-plan`, `ce-work`, `ce-compound`) whose output isn't verdict-shaped. Define precedence: the await-input sentinel, when present, takes priority and the verdict is not required that turn; **gate** steps (`code-review`) still emit the verdict after their skill work; **non-gate** skill steps relax the verdict requirement (the executor already runs `parseAwaitInputSentinel` on output regardless). Implement by making the verdict instruction conditional on the step being a gate (or skill-less) step. *Rationale:* without this, the model is told to end two different ways and the gate parsing or the skill output breaks. *Note:* the preamble rides on the node prompt (`~6172`) while the verdict instruction rides on the step system prompt (`~12453`); U2/U6 must confirm both reach the same session and don't contradict.
- KTD-7 — **Confine and trust-bound the persona-def read before injecting it as `systemPromptOverride`.** The preamble has the skill read `$FUSION_CE_AGENTS_DIR/<persona>.md` and inject its body verbatim as a child's system prompt. Guard it: resolve the persona-def path and assert it stays within the install dir (reject `../` traversal in the persona name); require valid frontmatter and a body-size cap before use; and keep `FUSION_CE_AGENTS_DIR` plugin-installer-owned and **outside the repo worktree** — important now that coding-mode `plan`/`code-review` steps can write files and could otherwise reach an in-tree agents dir. *Rationale:* the defs are trusted today (vendored by the plugin installer), but injecting a file body verbatim into a spawned agent's system prompt is a filesystem prompt-injection surface if that dir is ever writable by anything else; the confinement check is cheap insurance.
---
## High-Level Technical Design
### Where the flow breaks today vs. the target
```mermaid
flowchart LR
subgraph Today["Today (degraded)"]
A1[Node names ce-work] --> A2["prompt text only:<br/>'Invoke the ce-work skill'"]
A2 --> A3[Step session loads<br/>role fallback 'fusion' ONLY]
A3 --> A4[Skill body never loaded;<br/>model improvises]
A4 --> A5["raw Task ce-* &rarr; no persona<br/>AskUserQuestion &rarr; lost"]
end
subgraph Target["Target (full CE)"]
B1[Node names ce-work] --> B2["skillName &rarr; requestedSkillNames<br/>+ CE dir &rarr; additionalSkillPaths"]
B2 --> B3[Step session LOADS ce-work]
B3 --> B4[Engine preamble adds<br/>Fusion conventions]
B4 --> B5["fan-out via systemPromptOverride<br/>questions via await-input"]
end
```
### Skill-loading data flow (KTD-1)
```mermaid
flowchart TD
N["WorkflowStep.skillName<br/>= compound-engineering:ce-work"] --> M[executeWorkflowStep]
M --> SC[buildSessionSkillContext<br/>role/agent skills]
M --> RN["merge skillName into<br/>requestedSkillNames (bare + namespaced)"]
E["stepEnv.FUSION_CE_SKILLS_DIR"] --> AP[additionalSkillPaths]
RN --> CR[createResolvedAgentSession]
AP --> CR
SC --> CR
CR --> RL["DefaultResourceLoader scans CE dir<br/>+ resolver keeps requested name"]
RL --> OK[ce-work SKILL.md loaded into session]
```
### Question handling: board vs. headless (KTD-3)
```mermaid
flowchart TD
Q[Skill has a user question] --> H{FUSION_HEADLESS set?}
H -->|yes: LFG/pipeline| AS[Record assumption,<br/>proceed]
H -->|no: board run| AI["emit ===FUSION_AWAIT_INPUT===<br/>&rarr; task awaiting-user-input"]
AI --> CB[Card button + banner<br/>&rarr; steering answer &rarr; resume]
```
---
## Changed Surfaces
```text
packages/
engine/src/executor.ts # U8 runGraphCustomNode (~5998/6247/6252): thread injected
# env into skill steps + register fn_spawn_agent (~8076/15148);
# U1 skill loading in executeWorkflowStep (~12409/12532/12564);
# U2 conventions preamble at skill-prompt build (~6172);
# KTD-6 verdict-contract reconciliation (~12453-12484);
# U3 FUSION_HEADLESS on stepEnv (~12553) via explicit flag;
# U9 systemPromptOverride path-confinement (~15217)
engine/src/__tests__/ # NEW e2e graph-path skill-step test (U6)
core/src/types.ts # add skillName to WorkflowStep (~610) + WorkflowStepInput (~734)
core/src/workflow-compiler.ts # carry skillName in nodeToStepInput (~240) + comment (~233)
core/src/workflow-steps-to-ir.ts # INVERSION CONTRACT: stepInputToNode + parity test (U1)
core/src/builtin-workflows.ts # U4 toolMode on plan/code-review/document
core/src/__tests__/builtin-workflows.test.ts # updated assertions (U7)
plugins/fusion-plugin-compound-engineering/
.fusion-ce-skills/ce-debug/SKILL.md # reconcile vs preamble (U5)
.fusion-ce-skills/ce-resolve-pr-feedback/... # reconcile + persona override (U5)
src/index.ts # FUSION_CE_SKILLS_DIR / FUSION_CE_AGENTS_DIR exposure
```
---
## Implementation Units
**Sequencing:** U8 is foundational — it threads the CE runtime env and registers the spawn tool on the graph-node path the workflow actually runs, and U1/U3/U4/U6 depend on it. Build U8 first, then U1 (skill loading) and U2 (preamble), then U3/U4/U5/U9, with U6/U7 locking the behavior in. Units are listed below in their original numeric order, not build order.
### U1. Load the named skill into the workflow-step session
- **Goal:** The skill a step names is actually discovered and selected in that step's session — the core "workflow execution loads skills" fix.
- **Requirements:** R1
- **Dependencies:** U8 (the graph-node path must thread `FUSION_CE_SKILLS_DIR` into the step env first, or this fix no-ops on the real workflow)
- **Files:** `packages/core/src/types.ts` (add `skillName?: string` to `WorkflowStep` ~610-655 **and** to `WorkflowStepInput` ~734); `packages/engine/src/executor.ts` **`~6230`** (the graph-node step-literal synthesis seam — copy `cfg.skillName` onto the synthesized `WorkflowStep`; this is the seam the built-in workflow runs, and it currently drops the field) and `executeWorkflowStep` (`~12409`, skill-context build `~12532`: merge the step's skill name into the resolved `skillSelection.requestedSkillNames`; pass `additionalSkillPaths: [stepEnv.FUSION_CE_SKILLS_DIR]` into `createResolvedAgentSession` `~12564`); `packages/core/src/workflow-compiler.ts` (`nodeToStepInput` ~240 + the compiler-visible-fields comment ~233 — carry `skillName`) and `packages/core/src/workflow-steps-to-ir.ts` (`stepInputToNode` + the round-trip **parity test** — the INVERSION CONTRACT at `workflow-compiler.ts:237` requires both sides updated together) for the persisted-step path.
- **Approach:** Carry `skillName` onto the `WorkflowStep` at **both** materialization sites — the graph-node literal at `~6230` (where `cfg.skillName` is already in scope from the `~6172` prompt build but not copied onto the step) and the legacy compiler `nodeToStepInput`. In `executeWorkflowStep`, after `buildSessionSkillContext`, if `workflowStep.skillName` is set, add both the namespaced and bare forms (the resolver's `bareSkillName` strips `compound-engineering:`) to the requested-names set, and pass the CE install root (read from `stepEnv.FUSION_CE_SKILLS_DIR`) as `additionalSkillPaths`. Mirror the interactive fix documented in `docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md` — discovery (path) AND selection (name) must both be satisfied. Honor the INVERSION CONTRACT: any field added to the compiler must be reflected in `stepInputToNode` and the parity test, or persisted-step round-tripping breaks.
- **Patterns to follow:** `compound-engineering-skill-resolution.test.ts` (the proven resolver contract); `pi.ts` `additionalSkillPaths` discovery seam; `index.ts:323` forwarding.
- **Test scenarios:**
- Happy path: a step with `skillName: "compound-engineering:ce-work"` produces a session whose resolved skills include `ce-work`.
- Bare-vs-namespaced: requesting `compound-engineering:ce-plan` resolves the bundled `ce-plan` dir.
- Discovery without selection: CE dir on the path but name not requested → skill not retained (asserts both halves are required).
- Edge: `FUSION_CE_SKILLS_DIR` unset → no throw; step still runs with role-fallback skills.
- Non-skill step (prompt executor) is unchanged (no requested name added).
- **Verification:** the named skill appears in the step session's resolved skill set; a real `DefaultResourceLoader` test (not a scripted session) proves it.
### U2. Inject the Fusion workflow-step conventions preamble for skill steps
- **Goal:** Teach every skill the Fusion conventions (await-input for questions; persona fan-out via `FUSION_CE_AGENTS_DIR` + `systemPromptOverride`) in one engine-side place.
- **Requirements:** R2
- **Dependencies:** none (composes with U1)
- **Files:** `packages/engine/src/executor.ts` (skill-prompt build, ~6172-6173).
- **Approach:** When `executorKind === "skill"` and the session is a workflow step, prepend a stable, skill-agnostic conventions block before the existing "Invoke the `<skill>` skill…" line. The block states: (1) you are a Fusion autonomous workflow step; `AskUserQuestion`/`request_user_input` have no listener — to ask the user, emit exactly one `===FUSION_AWAIT_INPUT=== … ===END_FUSION_AWAIT_INPUT===` block and stop (the executor already parses this, `executor.ts` ~974/6254); (2) when `FUSION_HEADLESS=1`, do not ask — record an assumption and proceed; (3) to dispatch a `ce-<persona>` subagent, read `$FUSION_CE_AGENTS_DIR/<persona>.md`, strip frontmatter, and pass the body as `systemPromptOverride` to `fn_spawn_agent`; if `fn_spawn_agent` is absent (readonly), do the persona's work inline. Keep the text short and declarative; it is read by every CE skill step.
- **Patterns to follow:** the existing prompt-prefix construction at 6172-6173; the sentinel grammar in `parseAwaitInputSentinel`; the `systemPromptOverride` param shape (`executor.ts` ~958).
- **Test scenarios:**
- A compiled skill step's prompt contains the conventions block ahead of the "Invoke the skill" line.
- A non-skill (prompt/gate) step does not get the block.
- The block references the await-input sentinel and `FUSION_CE_AGENTS_DIR` / `systemPromptOverride` verbatim (contract assertion so skills and engine agree on the grammar).
- **Verification:** golden-string assertion on the composed step prompt for a skill node.
### U3. Set `FUSION_HEADLESS` for genuinely-unattended runs
- **Goal:** Distinguish "no human now, but reachable async" (board) from "no human ever" (LFG/pipeline) so skills degrade honestly.
- **Requirements:** R3
- **Dependencies:** U2 (the preamble reads the var), U8 (shares the run-options → `runGraphCustomNode` → `stepEnv` threading seam)
- **Files:** `packages/engine/src/executor.ts` (`stepEnv` build `~12553`; thread a new `unattended` boolean from the workflow-run options through `runGraphCustomNode`/`executeWorkflowStep`); the LFG/pipeline run entrypoints that must set the flag (enumerate them — at minimum the `disable-model-invocation`/LFG pipeline caller).
- **Approach:** No origin signal exists to look up (verified — see KTD-3), so add an **explicit `unattended` boolean** on the workflow-run options and set it only at the LFG/pipeline/`disable-model-invocation` entrypoints. Thread it through the same seam U8 adds, and at the `stepEnv` build set `FUSION_HEADLESS=1` **iff** the flag is explicitly true. Default/absent → no `FUSION_HEADLESS` (board run). Leave `FUSION_WORKFLOW_STEP=1` always-on as today. Enumerate the entrypoints in the implementation rather than assuming a single marker.
- **Patterns to follow:** the existing `FUSION_WORKFLOW_STEP` injection at `~12553`; U8's env-threading change.
- **Test scenarios:**
- Board run (flag unset): `FUSION_HEADLESS` is absent on the step env.
- LFG/pipeline run (flag explicitly set): `FUSION_HEADLESS=1` on the step env.
- **Default-safe invariant:** absence of the explicit flag yields no `FUSION_HEADLESS` regardless of any other run-context attribute (guards against a heuristic creeping in).
- The variable never leaks into interactive (non-workflow) sessions.
- **Verification:** step env carries the var only when the explicit flag is set; combined with U2, a headless skill records assumptions instead of emitting an await-input block, and a board skill still parks.
### U4. Give the fan-out / writing CE nodes the right tool mode
- **Goal:** `ce-plan` and `ce-code-review` can spawn their subagents (paired with U8); `ce-compound` can write `docs/solutions`.
- **Requirements:** R4
- **Dependencies:** U8 (coding mode is necessary but not sufficient for spawn — U8 registers `fn_spawn_agent` for coding-mode skill steps)
- **Files:** `packages/core/src/builtin-workflows.ts` (the `plan`, `code-review`, and `document` nodes in `builtin:compound-engineering` ~186-258).
- **Approach:** Add `toolMode: "coding"` to the `plan`, `code-review`, and `document` node configs (execute/commit-pr/resolve-feedback already coding; merge stays the generic Fusion boundary per the prior plan's KTD-6). Add a short comment on each noting *why* coding is required (spawn for plan/review — once U8 lands; write for document) and that, per the documented write-capability posture (KTD-4), plan/review are not supposed to edit code and U6 asserts they produce no diff.
- **Patterns to follow:** the existing `toolMode: "coding"` execute/commit-pr nodes in the same file; `workflow-step-tool-policy.ts` allow/deny lists.
- **Test scenarios:**
- `compileWorkflowToSteps` yields `plan`, `code-review`, `document` steps with compiled `toolMode === "coding"`.
- `execute`, `commit-pr`, `resolve-feedback` remain coding; `merge` remains the generic boundary.
- Readonly-stripping does not fire for these three steps (no `[readonly-violation]` log).
- **Verification:** updated `builtin-workflows.test.ts` asserts the tool modes; combined with U8, a coding-mode step exposes `fn_spawn_agent` and write tools (before U8, spawn is absent in both modes — see U6).
### U5. Reconcile the two adapted skills and verify all 11 against the preamble
- **Goal:** Cover all 11 bundled skills under the preamble without double-instruction, and close `ce-resolve-pr-feedback`'s persona-override gap.
- **Requirements:** R2, R5
- **Dependencies:** U2
- **Files:** `plugins/fusion-plugin-compound-engineering/.fusion-ce-skills/ce-debug/SKILL.md`, `.fusion-ce-skills/ce-resolve-pr-feedback/SKILL.md` (+ its `references/full-mode.md`), and an audit pass over the remaining nine (`ce-brainstorm`, `ce-code-review`, `ce-commit`, `ce-commit-push-pr`, `ce-compound`, `ce-ideate`, `ce-plan`, `ce-strategy`, `ce-work`).
- **Scope note (all 11, with caveat):** Per the scope decision the audit covers all 11 bundled skills, but only **6 are current `builtin:compound-engineering` nodes** (`ce-plan`, `ce-work`, `ce-code-review`, `ce-commit-push-pr`, `ce-resolve-pr-feedback`, `ce-compound`). The other 5 (`ce-brainstorm`, `ce-ideate`, `ce-strategy`, `ce-commit`, `ce-debug`) are **not** workflow nodes today — the preamble fires only when a skill runs as a workflow step, so R2/R5 coverage for them is **forward-looking** (it pays off if/when they become nodes). State this explicitly rather than implying an active consumer. `ce-debug`'s JSON protocol in particular only matters for standalone/interactive debug invocation, not the workflow — do not put it on the workflow-critical path.
- **Approach:** With the preamble supplying the generic conventions, remove or slim the *duplicated* in-place instructions in `ce-debug` and `ce-resolve-pr-feedback` so the same guidance isn't stated twice (keep only genuinely bespoke behavior — e.g. `ce-debug`'s JSON protocol, which is structurally incompatible with the plaintext sentinel and is a standalone-invocation caveat, not a workflow reconciliation). For `ce-resolve-pr-feedback`, ensure its `ce-pr-comment-resolver` dispatch follows the preamble's persona-override pattern. For the rest, confirm the preamble is sufficient by **enumerating** each skill's persona list and question sites and mapping them onto await-input + `systemPromptOverride`; add a brief per-skill note only where the generic preamble can't express what the skill needs. Do not re-fork skills the preamble already covers.
- **Patterns to follow:** the existing `FUSION_WORKFLOW_STEP` branch in `ce-resolve-pr-feedback/references/full-mode.md`; `ce-debug`'s JSON-protocol section; upstream skill structure (keep diffs minimal for re-vendoring).
- **Test scenarios:**
- **Override-correctness (behavioral):** for at least one verbatim skill (`ce-plan`), assert at runtime that under the preamble the skill emits a `===FUSION_AWAIT_INPUT===` block (or, headless, records an assumption) rather than calling `AskUserQuestion` — i.e. the preamble wins over the skill's own "call `AskUserQuestion`" text. (Covered in U6.)
- **Persona override (behavioral):** a `ce-resolve-pr-feedback` step spawns its resolver child with a non-empty `systemPromptOverride` sourced from the persona def. (Covered in U6.)
- **No double-instruction:** the composed `ce-debug` / `ce-resolve-pr-feedback` step prompt contains the convention guidance exactly once (engine preamble), not duplicated by the skill body.
- Manifest/install assertions: all 11 skills still install (no regression from edits).
- **Verification:** the U6 behavioral assertions above pass; `ce-debug`/`ce-resolve` do not emit duplicated convention text; the per-skill persona/question mapping is recorded in the implementation notes.
### U6. End-to-end `executor: "skill"` workflow-step test
- **Goal:** Lock in the behavior that silently regressed — prove a skill step loads the skill, can spawn, carries the preamble, and pauses on a question.
- **Requirements:** R6, R7
- **Dependencies:** U1, U2, U4, U8 (the test drives the graph-node path, which only works once U8 threads env + spawn)
- **Files:** `packages/engine/src/__tests__/` (new test mirroring executor/step-session test harnesses), using a real `DefaultResourceLoader` and the bundled CE skill fixtures.
- **Approach:** Drive the **graph-node path** (`runGraphCustomNode` → `executeWorkflowStep`) for a CE skill node — not only the legacy caller — so the test exercises the seam the built-in workflow actually uses. Assert: (1) `FUSION_CE_SKILLS_DIR`/`FUSION_CE_AGENTS_DIR` are present in the step session env at runtime (R7/U8); (2) the resolved session skills include the named skill (U1); (3) the composed step prompt contains the conventions preamble exactly once (U2); (4) a coding-mode skill step exposes `fn_spawn_agent` after U8 (and a readonly step does not) — note this assertion **fails against pre-U8 code where spawn is absent in both modes**, so U8 must land first; (5) emitting `===FUSION_AWAIT_INPUT===` parks the task `awaiting-user-input` with a parseable marker and a steering answer resumes it; (6) the verdict-JSON contract and the sentinel coexist per KTD-6 (a non-gate skill step does not fail for lacking a verdict; a gate step still emits one); (7) a `ce-resolve-pr-feedback` step spawns its resolver child with a non-empty `systemPromptOverride`; (8) `plan`/`code-review` steps produce no working-tree diff (write-capability detection per KTD-4). Reuse the resolver fixtures from `compound-engineering-skill-resolution.test.ts`.
- **Patterns to follow:** `compound-engineering-skill-resolution.test.ts`, the step-session executor tests, the await-input pause/resume assertions.
- **Test scenarios:**
- Env present at runtime on the graph path: `FUSION_CE_SKILLS_DIR`/`FUSION_CE_AGENTS_DIR` reach the step session.
- Skill loaded: named CE skill present in resolved session skills.
- Preamble present exactly once: conventions block in the step prompt, not duplicated.
- Spawn gating (post-U8): `fn_spawn_agent` present in coding, absent in readonly.
- Verdict/sentinel coexistence: non-gate skill step OK without verdict; gate step emits verdict.
- Persona override: `ce-resolve-pr-feedback` resolver child spawned with non-empty `systemPromptOverride`.
- Override-correctness: `ce-plan` emits the await-input sentinel (board) / records an assumption (headless) instead of calling `AskUserQuestion`.
- Write detection: `plan`/`code-review` steps leave the tree unmodified.
- Await-input: sentinel → `awaiting-user-input` + marker; steering reply → resume.
- **Verification:** `pnpm --filter @fusion/engine test` green including the new file.
### U7. Update built-in workflow tests
- **Goal:** Pin the node-level wiring and tool modes.
- **Requirements:** R6
- **Dependencies:** U1, U4
- **Files:** `packages/core/src/__tests__/builtin-workflows.test.ts`.
- **Approach:** Extend the compound-engineering assertions to cover: each skill node carries its `skillName` onto the compiled step; `plan`/`code-review`/`document` compile to `toolMode: "coding"`; step ordering (plan → execute → review → code-review → commit-pr → resolve-feedback → merge → document) is intact; plugin gating still holds.
- **Patterns to follow:** existing assertions in the same file.
- **Test scenarios:**
- `skillName` present on each skill step.
- Tool modes for plan/code-review/document/execute/commit-pr/resolve-feedback.
- Workflow hidden without the plugin, shown with it.
- **Verification:** `pnpm --filter @fusion/core test builtin-workflows` green.
### U8. Thread the injected runtime env and register `fn_spawn_agent` on the graph-node step path
- **Goal:** The seam the built-in workflow actually runs (`runGraphCustomNode`) must hand the skill-step session the injected CE env **and** the spawn tool — without this, U1 (skill loading), U3 (headless), and persona fan-out all silently no-op. **Foundational — U1, U3, U4, U6 depend on it.**
- **Requirements:** R7 (and unblocks R1, R2, R3)
- **Dependencies:** none
- **Files:** `packages/engine/src/executor.ts` — `runGraphCustomNode` (`~5998`; its call site `~4158` and the `executeWorkflowStep` call at `~6252` pass `nodeEnv`, which is `undefined` for skill nodes — `~6247`); the injected `taskEnv` build at `~7445` (`collectExecutorRuntimeEnv`); the spawn-tool registration `createSpawnAgentTool` (`~15148`, today wired only into the main session at `~8076`); the `executeWorkflowStep` custom-tools assembly (`~12554-12581`).
- **Approach:** Two coupled changes on the graph path. **(a) Env:** make the injected runtime env (carrying `FUSION_CE_SKILLS_DIR`/`FUSION_CE_AGENTS_DIR`, built from `collectExecutorRuntimeEnv`) reachable inside `runGraphCustomNode`, and pass it as the `taskEnv` argument to `executeWorkflowStep` for skill nodes (today only CLI nodes get a non-`undefined` env). The legacy caller at `~12116` already passes `taskEnv` — this closes the graph-vs-legacy asymmetry. **(b) Spawn:** register `createSpawnAgentTool` into the custom-tool set for coding-mode skill steps in `executeWorkflowStep`, so `fn_spawn_agent` is actually present where the CE skills fan out (today it's never registered there; coding mode passes an empty custom-tool set). Gate it to coding-mode steps; readonly steps keep no spawn. Account for the per-child git worktree cost the spawn tool incurs (see Deferred: lighter read-only spawn path).
- **Patterns to follow:** the main-session tool assembly at `~8052-8082` (how `createSpawnAgentTool` is constructed and passed); the legacy `executeWorkflowStep` caller at `~12116` that already threads `taskEnv`; the `FUSION_WORKFLOW_STEP` injection at `~12553`.
- **Test scenarios:**
- Graph-path skill step session env contains the injected `FUSION_CE_*` keys (was empty/`process.env` before).
- A coding-mode skill step on the graph path has `fn_spawn_agent` available; a readonly one does not.
- The legacy `executeWorkflowStep` caller behavior is unchanged (no regression).
- A spawned child still runs with `systemPromptOverride` honored (composes with the existing primitive).
- **Verification:** the U6 end-to-end test (driving the graph path) sees the env keys and the spawn tool; a manual CE board run logs the resolved skills and a successful persona spawn.
### U9. Confine the persona-def read and document the write-capability posture
- **Goal:** Close the filesystem prompt-injection surface KTD-7 names, and make the accepted write-capability risk (KTD-4) explicit in the plan and code comments.
- **Requirements:** R2, R5 (safety of the fan-out path)
- **Dependencies:** U2 (preamble defines the read), U8 (spawn must exist for the override to be used)
- **Files:** `packages/engine/src/executor.ts` (the `systemPromptOverride` consumption path `~15217` — add path-confinement/size validation if the engine ever resolves the def; otherwise the guidance lives in the preamble text from U2); the preamble text (U2) instructing the path-confined read; plugin docs noting `FUSION_CE_AGENTS_DIR` must be installer-owned and outside the worktree.
- **Approach:** Have the preamble (U2) instruct a path-confined read: resolve `<persona>.md` and require it stays within `FUSION_CE_AGENTS_DIR` (reject `../` in the persona name), require valid frontmatter, and cap body size before passing as `systemPromptOverride`. Confirm `FUSION_CE_AGENTS_DIR` resolves outside the repo worktree (so coding-mode `plan`/`code-review` steps can't write into it). Add a code comment + a Risk-1 note documenting that plan/review write capability is model-discipline-only until the readonly-plus-spawn mode ships (the chosen posture).
- **Patterns to follow:** the `assertPluginLocalAgentsTarget` guard already in `plugins/fusion-plugin-compound-engineering/src/agent-installation.ts` (mirror its path-confinement style).
- **Test scenarios:**
- A persona name containing `../` is rejected before any read.
- `FUSION_CE_AGENTS_DIR` resolves outside the task worktree.
- `Test expectation:` the write-posture documentation is prose/comments — no behavioral test beyond U6's plan/code-review no-diff assertion.
- **Verification:** traversal attempt rejected; agents dir confirmed out-of-tree; Risk-1 posture documented.
---
## Scope Boundaries
**In scope:** loading the named skill into step sessions (R1); the engine conventions preamble for all 11 bundled skills (R2); the `FUSION_HEADLESS` unattended signal (R3); tool-mode fixes for plan/code-review/document (R4); reconciling the two already-adapted skills + the resolver persona override (R5); the end-to-end and node-level tests (R6).
### Deferred to Follow-Up Work
- A dedicated **readonly-plus-spawn** tool mode (spawn without write) so `plan`/`code-review` can fan out without exposing edit/write — today coding is the only mode carrying `fn_spawn_agent`.
- A **lighter spawn path** that avoids a full git worktree per child for read-only reviewer personas (the `ce-code-review` panel can fan out wide; each `fn_spawn_agent` child currently gets its own worktree).
- A general **plugin-provided agent-definition registry** API (this plan keeps the file-based `FUSION_CE_AGENTS_DIR` + `systemPromptOverride` approach).
- Applying the preamble / headless signal to the **other** built-in workflows (`builtin:coding`, `builtin:stepwise-coding`, `builtin:marketing`, etc.).
- Evidence-capture / demo-reel integration in the PR flow.
### Out of scope
- Redesigning the workflow engine, the await-input mechanism, or the workflow-owned-merge system.
- Changing the CE skills' interactive (Claude Code) behavior — the preamble only fires in workflow-step sessions.
- The dashboard "Answer questions" button/banner — already shipped and tested.
---
## Risks & Dependencies
- Risk-1 (high, **accepted + documented**) — **Coding mode on `plan`/`code-review` exposes write/edit with no engine guard.** Per the chosen posture (KTD-4), the only protection is skill discipline plus a U6 **detection** assertion (a non-empty diff on plan/code-review is flagged, not blocked). An autonomous run — especially headless, where the skill is told to proceed without asking — could mutate the tree before code is written and corrupt the artifact the next step consumes. *Mitigation:* documented model-discipline-only posture until the proper guard (readonly-plus-spawn tool mode) ships (Deferred); U6 detects violations; re-evaluate before enabling the CE workflow for LFG/pipeline. *This is a knowingly-accepted gap, not a closed one.*
- Risk-2 (high) — **The preamble must override 9 unforked upstream skill bodies that still say "call `AskUserQuestion`" / "`Task ce-*`".** The maintenance win of the preamble approach holds only if a generic block reliably wins over each skill's own contradicting text; the real exposure is these 9, not `ce-debug` (which isn't a workflow node). *Mitigation:* U5/U6 assert override-correctness behaviorally for at least `ce-plan`; each upstream re-vendor needs a re-check that no skill body defeats the preamble. *`ce-debug`'s JSON protocol is a standalone-invocation caveat, not a workflow collision.*
- Risk-3 (med) — **`FUSION_HEADLESS` misdetection.** A wrong signal could make a board run skip questions (proceed on assumptions when a human was available) or an LFG run park forever. *Mitigation:* KTD-3 makes it an **explicit opt-in** flag with a default-safe invariant (absent → board), threaded through U8's seam; both directions covered in U3 tests. The safe default means a miss can only park a recoverable task, never silently skip approval.
- Risk-4 (med) — **The graph-node path doesn't thread the injected env (root cause).** `FUSION_CE_SKILLS_DIR`/`FUSION_CE_AGENTS_DIR` are exported by the plugin (`index.ts`, both confirmed present) but never reach the graph-node step session, so without U8 the skill-loading and fan-out fixes no-op on the real workflow. *Mitigation:* U8 closes the graph-vs-legacy asymmetry; U6 asserts the env at runtime on the graph path (not a hand-fed `stepEnv`).
- Risk-5 (med) — **Persona-def injection surface.** The preamble reads `<persona>.md` and injects its body verbatim as a child `systemPromptOverride`. If `FUSION_CE_AGENTS_DIR` is ever writable by something other than the plugin installer (compromised update, in-tree dir reachable by a coding-mode step, `../` traversal), arbitrary instructions enter a spawned agent's system prompt. *Mitigation:* U9 path-confinement + out-of-tree dir + the existing `assertPluginLocalAgentsTarget` install guard.
- Risk-6 (med) — **Verdict-contract vs. skill output collision.** The step system prompt requires a trailing verdict JSON; the preamble tells skills to emit a sentinel / skill-native output. Unreconciled, gate parsing or skill output breaks. *Mitigation:* KTD-6 defines precedence (sentinel wins; gate steps still emit verdict; non-gate skill steps relax it); U6 asserts coexistence.
- Dependency: U6's e2e test depends on the bundled CE skill fixtures being installable in the test harness (same fixtures `compound-engineering-skill-resolution.test.ts` already uses).
---
## Verification Strategy
- Unit/integration tests per unit, centered on a new engine e2e skill-step test (U6) and `builtin-workflows.test.ts` (U7), plus the existing resolver and await-input tests staying green.
- A manual autonomous board run of `builtin:compound-engineering` on a real task, confirming: (1) each step's named CE skill is actually loaded (log the resolved session skills); (2) `ce-plan` / `ce-code-review` fan out to `ce-*` personas via `systemPromptOverride`; (3) a planning question parks the task and the card button → banner → steering answer resumes it; (4) `ce-compound` writes a `docs/solutions` entry; (5) an LFG run of the same workflow records assumptions instead of parking.
- `pnpm` typecheck + affected package suites (`@fusion/engine`, `@fusion/core`) green before PR.
---
## Sources & Research
- Node wiring + skill nodes: `packages/core/src/builtin-workflows.ts:178-260`; tool policy `packages/engine/src/workflow-step-tool-policy.ts`.
- Skill-prompt build (text-only injection today): `packages/engine/src/executor.ts:6172-6173`.
- Step session + skill selection (no `skillName` / `additionalSkillPaths` forwarded): `executeWorkflowStep` at `packages/engine/src/executor.ts:12409` (skill-context build `~12532`, `createResolvedAgentSession` `~12564`); `session-skill-context.ts` (role fallback `executor → fusion`); resolver `skill-resolver.ts:211-321`.
- **Verified during doc review (root-cause seams):** the built-in workflow runs via the graph-node path `runGraphCustomNode` (`executor.ts:5998`, called `~4158`), which synthesizes the step at `~6230` (no `skillName` copied) and calls `executeWorkflowStep` with `nodeEnv = undefined` for skill nodes (`~6247/6252`) — while the legacy caller at `~12116` passes `taskEnv`. `fn_spawn_agent` (`createSpawnAgentTool` `~15148`) is registered only in the main session (`~8076`), never in `executeWorkflowStep` (coding passes an empty custom-tool set, `~12554-12581`). The step system prompt mandates a trailing `{"verdict":...}` JSON object (`~12453-12484`). The `fusion` role-fallback skill carries no CE behavior (confirmed — no partial-credit fallback). `WorkflowStep`/`WorkflowStepInput` lack `skillName` (`types.ts:610, 734`); compiler INVERSION CONTRACT at `workflow-compiler.ts:237`. `FUSION_HEADLESS` is only a reservation comment (`~12552`); no LFG/pipeline origin marker reaches the step walk.
- `additionalSkillPaths` discovery seam: `packages/engine/src/pi.ts:993, 2077-2078`; `packages/engine/src/index.ts:323`; core seam `packages/core/src/plugin-types.ts:179`.
- `systemPromptOverride` primitive: `packages/engine/src/executor.ts:958-963, 15162-15239` (child gets its own worktree ~15204-15207).
- Await-input sentinel + pause/resume: `packages/engine/src/executor.ts:974-979, 6254-6273` and `runAwaitInputNode` ~5674-5728.
- `FUSION_WORKFLOW_STEP` set / `FUSION_HEADLESS` reserved: `packages/engine/src/executor.ts:12551-12553`.
- Plugin env exposure (`FUSION_CE_AGENTS_DIR`, runtime env hook): `plugins/fusion-plugin-compound-engineering/src/index.ts:148-181`; `agent-installation.ts`; runtime-env merge `executor.ts:7434-7452`.
- Bundled skills (2 adapted, 9 verbatim): `plugins/fusion-plugin-compound-engineering/.fusion-ce-skills/` — `ce-debug` (JSON protocol), `ce-resolve-pr-feedback/references/full-mode.md` (`FUSION_WORKFLOW_STEP` await-input branch); the rest call `AskUserQuestion` + raw `Task ce-*`.
- Dashboard pause/resume (shipped): `packages/dashboard/app/components/TaskCard.tsx:2048-2061`, `WorkflowResultsTab.tsx:68-76, 922-951`, `app/api/legacy.ts:5376-5381`.
- Prior plan + skill-loading learning: `docs/plans/2026-06-13-002-feat-compound-engineering-workflow-integration-plan.md`; `docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md`.
- Tests: `packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts`; `packages/core/src/__tests__/builtin-workflows.test.ts`.

View File

@@ -393,6 +393,26 @@ describe("built-in workflows", () => {
expect(ids.indexOf("merge")).toBeLessThan(ids.indexOf("document"));
});
it("compound-engineering runs plan/code-review/document in coding mode and carries skillName onto compiled steps (U1/U4)", () => {
const ce = getBuiltinWorkflow("builtin:compound-engineering")!;
const byId = (id: string) => ce.ir.nodes.find((n) => n.id === id);
// U4: fan-out steps (plan, code-review) need coding so fn_spawn_agent is
// available for persona fan-out; document needs coding to WRITE docs/solutions.
expect(byId("plan")?.config?.toolMode).toBe("coding");
expect(byId("code-review")?.config?.toolMode).toBe("coding");
expect(byId("document")?.config?.toolMode).toBe("coding");
// U1: the compiler carries each node's skillName onto the materialized step so
// the step session can actually LOAD the skill (not just name it in prompt text).
const steps = compileWorkflowToSteps(ce.ir);
const plan = steps.find((s) => s.name === "Plan");
expect(plan?.skillName).toBe("compound-engineering:ce-plan");
expect(plan?.toolMode).toBe("coding");
const codeReview = steps.find((s) => s.skillName === "compound-engineering:ce-code-review");
expect(codeReview?.toolMode).toBe("coding");
const document = steps.find((s) => s.skillName === "compound-engineering:ce-compound");
expect(document?.toolMode).toBe("coding");
});
describe("store integration", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;

View File

@@ -16,6 +16,7 @@ function step(overrides: Partial<WorkflowStep>): WorkflowStep {
gateMode: overrides.gateMode ?? "advisory",
prompt: overrides.prompt ?? "",
toolMode: overrides.toolMode,
skillName: overrides.skillName,
scriptName: overrides.scriptName,
enabled: overrides.enabled ?? true,
defaultOn: overrides.defaultOn,
@@ -38,6 +39,7 @@ function visible(input: WorkflowStepInput) {
prompt: input.mode === "script" ? undefined : (input.prompt ?? ""),
scriptName: input.scriptName,
toolMode: input.mode === "script" ? undefined : input.toolMode,
skillName: input.mode === "script" ? undefined : input.skillName,
modelProvider: input.modelProvider,
modelId: input.modelId,
};
@@ -52,6 +54,7 @@ function visibleStep(s: WorkflowStep) {
prompt: s.mode === "script" ? undefined : (s.prompt ?? ""),
scriptName: s.mode === "script" ? s.scriptName : undefined,
toolMode: s.mode === "script" ? undefined : (s.toolMode ?? "readonly"),
skillName: s.mode === "script" ? undefined : s.skillName,
modelProvider: s.mode === "prompt" ? s.modelProvider : undefined,
modelId: s.mode === "prompt" ? s.modelId : undefined,
};
@@ -89,6 +92,18 @@ describe("stepsToWorkflowIr — round-trip parity (R4/KTD-2)", () => {
modelId: "claude-sonnet-4-5",
phase: "pre-merge",
}),
// U1 / INVERSION CONTRACT: a skill-executor step (pre-merge, grouped with
// the other pre-merge steps so declaration order matches compiled order)
// must round-trip its skillName through stepInputToNode → nodeToStepInput.
step({
id: "WS-6",
name: "CE skill step",
mode: "prompt",
gateMode: "advisory",
prompt: "Invoke the skill",
skillName: "compound-engineering:ce-work",
phase: "pre-merge",
}),
step({
id: "WS-4",
name: "Document",

View File

@@ -187,6 +187,10 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
name: "Plan",
executor: "skill",
skillName: "compound-engineering:ce-plan",
// Coding mode so ce-plan can fan out to its research subagents via
// fn_spawn_agent (registered only for coding-mode steps). It is not
// meant to write — see the accepted write-capability posture (Risk-1).
toolMode: "coding",
prompt: "Produce a short implementation plan for this task before any code is written.",
},
},
@@ -213,6 +217,10 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
executor: "skill",
skillName: "compound-engineering:ce-code-review",
gateMode: "gate",
// Coding mode so ce-code-review can fan out to its reviewer-persona
// subagents via fn_spawn_agent. As a gate step it still emits the
// verdict JSON (KTD-6); it is not meant to write the tree (Risk-1).
toolMode: "coding",
prompt: "Run a structured code review of the changes. Block merge on P0/P1 findings.",
},
},
@@ -253,6 +261,9 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
name: "Document learnings",
executor: "skill",
skillName: "compound-engineering:ce-compound",
// Coding mode so ce-compound can WRITE the learning doc into
// docs/solutions (readonly would strip write tools).
toolMode: "coding",
prompt: "Capture any reusable learnings from this task into docs/solutions.",
},
},

View File

@@ -626,6 +626,11 @@ export interface WorkflowStep {
prompt: string;
/** Tool set available to prompt-mode workflow agents. Defaults to readonly. */
toolMode?: WorkflowStepToolMode;
/** Name of a skill to load into this step's session (e.g.
* "compound-engineering:ce-work"). When set, the step session loads the named
* skill (discovery + selection) and the engine injects the Fusion workflow-step
* conventions preamble. Only meaningful for skill-executor graph nodes. */
skillName?: string;
/** Name of a script from project settings `scripts` map to execute (required when mode is "script") */
scriptName?: string;
/** Whether this step is available for selection on new tasks */
@@ -746,6 +751,9 @@ export interface WorkflowStepInput {
prompt?: string;
/** Tool set available to prompt-mode workflow agents. Defaults to readonly. */
toolMode?: WorkflowStepToolMode;
/** Name of a skill to load into this step's session (e.g.
* "compound-engineering:ce-work"). See `WorkflowStep.skillName`. */
skillName?: string;
/** Script name from project settings (required when mode is "script").
* Must reference a named script in `settings.scripts` — no raw commands. */
scriptName?: string;

View File

@@ -231,8 +231,9 @@ function defaultGateMode(node: WorkflowIrNode, mode: "prompt" | "script"): Workf
* its exact inverse is `stepInputToNode` in `workflow-steps-to-ir.ts`. Parity is
* pinned by `__tests__/workflow-steps-to-ir.test.ts` over exactly the
* compiler-visible fields: name / mode / phase / gateMode / prompt / scriptName /
* toolMode / modelProvider / modelId. `enabled` / `defaultOn` / `templateId` are
* NOT compiler-visible and are handled by migration policy, not the converter.
* toolMode / skillName / modelProvider / modelId. `enabled` / `defaultOn` /
* `templateId` are NOT compiler-visible and are handled by migration policy, not
* the converter.
*
* INVERSION CONTRACT: when you add a field here, extend `stepInputToNode` (and
* the parity test) in `workflow-steps-to-ir.ts` to keep the round-trip exact.
@@ -255,6 +256,10 @@ function nodeToStepInput(node: WorkflowIrNode, phase: "pre-merge" | "post-merge"
} else {
input.prompt = configString(node, "prompt") ?? "";
input.toolMode = node.config?.toolMode === "coding" ? "coding" : "readonly";
// Carry the node's skill name so the step session can load it (U1). Only
// present on skill-executor nodes; omitted otherwise to keep round-trip exact.
const skillName = configString(node, "skillName");
if (skillName) input.skillName = skillName;
const provider = configString(node, "modelProvider");
const modelId = configString(node, "modelId");
if (provider && modelId) {

View File

@@ -12,10 +12,10 @@ import { parseWorkflowIr } from "./workflow-ir.js";
* compileWorkflowToSteps(stepsToWorkflowIr(steps, name)) ≡ steps
*
* over exactly the compiler-visible fields: name / mode / phase / gateMode /
* prompt / scriptName / toolMode / modelProvider / modelId. `enabled` /
* `defaultOn` / `templateId` / `migratedFragmentId` are NOT compiler-visible and
* are handled by migration policy (KTD-3), not by this converter. Parity is
* pinned by `__tests__/workflow-steps-to-ir.test.ts`.
* prompt / scriptName / toolMode / skillName / modelProvider / modelId.
* `enabled` / `defaultOn` / `templateId` / `migratedFragmentId` are NOT
* compiler-visible and are handled by migration policy (KTD-3), not by this
* converter. Parity is pinned by `__tests__/workflow-steps-to-ir.test.ts`.
*
* INVERSION CONTRACT: when a compiler-visible field is added to `nodeToStepInput`
* (see the contract comment there), extend `stepInputToNode` below and the parity
@@ -68,6 +68,9 @@ function stepInputToNode(step: WorkflowStep, id: string): WorkflowIrNode {
// prompt mode
config.prompt = step.prompt ?? "";
config.toolMode = step.toolMode === "coding" ? "coding" : "readonly";
// Skill name round-trips when set (U1). The compiler reads it back via
// configString(node, "skillName"), so this keeps the inverse exact.
if (step.skillName) config.skillName = step.skillName;
// Model overrides only round-trip when BOTH are present (compiler requirement).
if (step.modelProvider && step.modelId) {
config.modelProvider = step.modelProvider;

View File

@@ -0,0 +1,187 @@
/**
* Compound-Engineering workflow-step skill-loading — focused unit coverage for
* the plan units U8/U1/U2/U3/U9 engine surface that does NOT require a full
* executor e2e:
*
* 1. The exported `FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE` constant carries
* the await-input sentinel grammar, the FUSION_HEADLESS degrade
* instruction, and the persona-fan-out / systemPromptOverride /
* path-confinement instruction (always feasible — pure constant assertion).
*
* 2. Skill resolution: a skill step requesting BOTH the namespaced
* `compound-engineering:ce-X` and bare `ce-X` forms (exactly what
* executeWorkflowStep merges into requestedSkillNames) resolves the named
* CE skill once the install dir is fed as a discovery path — the bare name
* matches case-insensitively against the discovered SKILL.md. This mirrors
* and extends compound-engineering-skill-resolution.test.ts, asserting the
* DUAL (namespaced + bare) request form U1 now produces.
*
* The full runGraphCustomNode -> executeWorkflowStep session path (skillName on
* the synthesized step, spawn gating, FUSION_HEADLESS, verdict conditional) is
* covered separately in ce-workflow-step-executor.test.ts (driving the real
* executor with a mocked agent session).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadSkills } from "@earendil-works/pi-coding-agent";
import { FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE } from "../executor.js";
import {
createSkillsOverrideFromSelection,
resolveSessionSkills,
} from "../skill-resolver.js";
vi.mock("../logger.js", () => {
const mk = () => ({ log: vi.fn(), warn: vi.fn(), error: vi.fn() });
return {
createLogger: vi.fn(() => mk()),
piLog: mk(),
schedulerLog: mk(),
executorLog: mk(),
planLog: mk(),
mergerLog: mk(),
worktreePoolLog: mk(),
reviewerLog: mk(),
prMonitorLog: mk(),
runtimeLog: mk(),
ipcLog: mk(),
projectManagerLog: mk(),
hybridExecutorLog: mk(),
formatError: (err: unknown) =>
err instanceof Error ? { message: err.message, detail: err.stack ?? err.message } : { message: String(err), detail: String(err) },
};
});
describe("FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE (U2/U9 constant)", () => {
it("documents the await-input sentinel grammar verbatim", () => {
// These exact tokens are the cross-module contract with parseAwaitInputSentinel.
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toContain("===FUSION_AWAIT_INPUT===");
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toContain("===END_FUSION_AWAIT_INPUT===");
// It must tell the skill to emit exactly one block and STOP.
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toMatch(/emit EXACTLY ONE block/);
});
it("carries the FUSION_HEADLESS degrade-to-assumption instruction (U3)", () => {
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toContain("FUSION_HEADLESS=1");
// In headless mode the skill must NOT ask and must record an assumption.
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toMatch(/do NOT ask the user/i);
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toMatch(/record a reasonable assumption/i);
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toMatch(/never emit the await-input block in this mode/i);
});
it("carries the persona fan-out / systemPromptOverride / path-confinement instruction (U8/U9)", () => {
// Persona def is read from the agents dir env var…
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toContain("$FUSION_CE_AGENTS_DIR/<persona>.md");
// …and passed to fn_spawn_agent as systemPromptOverride (the U8 fan-out contract).
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toContain("systemPromptOverride");
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toContain("fn_spawn_agent");
// Path confinement (the U9 filesystem prompt-injection guard): reject ../ traversal.
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toMatch(/reject any[\s\S]*path traversal/i);
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toMatch(/Resolve the path strictly inside/i);
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toContain("$FUSION_CE_AGENTS_DIR");
// Readonly fallback: if spawn is unavailable, do the persona's work inline.
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toMatch(/readonly step.*inline/i);
});
it("overrides contrary skill-body instructions (the conventions win)", () => {
expect(FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE).toMatch(/override any contrary instruction in the skill body/i);
});
});
/**
* U1 dual-form resolution. executeWorkflowStep merges BOTH the namespaced
* `compound-engineering:ce-work` and the bare `ce-work` into requestedSkillNames
* before resolution.
*
* REAL FINDING (asserted below): the resolver's name match is `bareSkillName`,
* which only strips a trailing `/SKILL.md` — it does NOT strip a `namespace:`
* prefix. So the NAMESPACED form alone does NOT match the on-disk bare
* `ce-work`; only the BARE form does. This is precisely why executeWorkflowStep
* must merge both forms — the bare half is what actually selects the skill, and
* the namespaced half is a (currently non-matching) belt-and-suspenders entry.
*/
describe("U1: dual-form (namespaced + bare) CE skill resolution", () => {
let tmp: string;
let projectRootDir: string;
let agentDir: string;
let installRoot: string;
function materialize(id: string): void {
const dir = join(installRoot, id);
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "SKILL.md"),
`---\nname: ${id}\ndescription: ${id} pipeline stage\n---\n\n# ${id}\n`,
);
}
function resolveFor(requestedSkillNames: string[]): string[] {
const discovered = loadSkills({
cwd: projectRootDir,
agentDir,
skillPaths: [installRoot],
includeDefaults: false,
});
const selection = resolveSessionSkills({
projectRootDir,
requestedSkillNames,
sessionPurpose: "executor",
});
const override = createSkillsOverrideFromSelection(selection, {
requestedSkillNames,
sessionPurpose: "executor",
});
const result = override({ skills: discovered.skills, diagnostics: discovered.diagnostics });
return result.skills.map((s) => s.name);
}
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "ce-conv-"));
projectRootDir = join(tmp, "project");
agentDir = join(tmp, "agent");
installRoot = join(tmp, ".fusion-ce-skills");
mkdirSync(projectRootDir, { recursive: true });
mkdirSync(agentDir, { recursive: true });
materialize("ce-work");
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("bare name `ce-work` resolves the installed skill", () => {
expect(resolveFor(["ce-work"])).toContain("ce-work");
});
it("namespaced name `compound-engineering:ce-work` ALONE does NOT resolve it (no namespace stripping)", () => {
// bareSkillName only strips `/SKILL.md`, not a `namespace:` prefix — so the
// namespaced tail never matches the on-disk bare `ce-work`. This is the gap
// that makes the bare half of the executor's dual merge load-bearing.
expect(resolveFor(["compound-engineering:ce-work"])).not.toContain("ce-work");
});
it("the dual request (both forms together, as executeWorkflowStep merges them) resolves it once via the bare half", () => {
const resolved = resolveFor(["compound-engineering:ce-work", "ce-work"]);
expect(resolved).toContain("ce-work");
// No duplicate skill entries from the two request forms.
expect(resolved.filter((n) => n === "ce-work")).toHaveLength(1);
});
it("without the install dir on the discovery path, the request does NOT resolve (both halves required)", () => {
// Repoint discovery away from the install root: name alone is insufficient.
const discovered = loadSkills({ cwd: projectRootDir, agentDir, skillPaths: [], includeDefaults: false });
const selection = resolveSessionSkills({
projectRootDir,
requestedSkillNames: ["compound-engineering:ce-work", "ce-work"],
sessionPurpose: "executor",
});
const override = createSkillsOverrideFromSelection(selection, {
requestedSkillNames: ["compound-engineering:ce-work", "ce-work"],
sessionPurpose: "executor",
});
const result = override({ skills: discovered.skills, diagnostics: discovered.diagnostics });
expect(result.skills.map((s) => s.name)).not.toContain("ce-work");
});
});

View File

@@ -0,0 +1,409 @@
/**
* Compound-Engineering workflow-step skill-loading — executor integration
* coverage. Drives the REAL TaskExecutor (over a mock store + mocked agent
* session, the established executor harness) through:
*
* - runGraphCustomNode (skill graph node) → asserts the synthesized
* WorkflowStep carries `skillName` and that the U2 conventions preamble is
* prepended to the prompt (item 3).
*
* - executeWorkflowStep directly → asserts, by capturing the exact
* session-creation args reaching createFnAgent:
* * spawn gating: fn_spawn_agent present in coding, absent in readonly (item 4)
* * FUSION_HEADLESS: on stepEnv only when unattended=true (item 5)
* * the step's named skill is merged into requestedSkillNames as BOTH the
* namespaced and bare form, and FUSION_CE_SKILLS_DIR is threaded as
* additionalSkillPaths (item 2, integration half)
* * verdict conditional: gate / skill-less step gets the verdict-JSON
* Feedback Format; a non-gate skill step gets the relaxed Output Format (item 6)
*
* HARNESS NOTE: createFnAgent is mocked (executor-test-helpers) so no real model
* runs. We assert on the arguments the executor hands the session layer — the
* engine-owned wiring — not on model behavior. The mock session emits a verdict
* line on prompt so the parse path completes cleanly.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import {
createMockStore,
mockedCreateFnAgent,
mockedExecSync,
resetExecutorMocks,
} from "./executor-test-helpers.js";
type CapturedSession = {
customTools?: Array<{ name?: string }>;
systemPrompt?: string;
taskEnv?: NodeJS.ProcessEnv;
skillSelection?: { requestedSkillNames?: string[] };
additionalSkillPaths?: string[];
};
/**
* Make createFnAgent capture its session-creation args and return a mock session
* that emits the given output line, then resolves. Returns the capture holder.
*/
function captureSession(output = '{"verdict":"APPROVE","notes":""}'): { last?: CapturedSession; all: CapturedSession[] } {
const holder: { last?: CapturedSession; all: CapturedSession[] } = { all: [] };
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
const captured: CapturedSession = {
customTools: opts.customTools,
systemPrompt: opts.systemPrompt,
taskEnv: opts.taskEnv,
skillSelection: opts.skillSelection,
additionalSkillPaths: opts.additionalSkillPaths,
};
holder.last = captured;
holder.all.push(captured);
const listeners: Array<(e: any) => void> = [];
const session: any = {
state: {},
subscribe: (fn: (e: any) => void) => {
listeners.push(fn);
return () => {};
},
prompt: vi.fn(async () => {
for (const fn of listeners) {
fn({
type: "message_update",
assistantMessageEvent: {
type: "text_delta",
partial: output,
contentIndex: 0,
delta: output,
},
});
}
}),
dispose: vi.fn(),
};
return { session };
});
return holder;
}
function makeExecutor(store: ReturnType<typeof createMockStore>) {
const agentStore = { getAgent: vi.fn().mockResolvedValue(null), createAgent: vi.fn() };
const executor = new TaskExecutor(store as any, "/tmp/test", { agentStore } as any);
return { executor, agentStore };
}
function baseStepTask(overrides: Record<string, unknown> = {}) {
return {
id: "FN-CE-1",
title: "CE",
description: "do the thing",
column: "in-progress" as const,
worktree: "/tmp/wt",
branch: "fusion/fn-ce-1",
baseCommitSha: "abc123",
dependencies: [],
steps: [{ name: "s", status: "in-progress" as const }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function makeStep(overrides: Record<string, unknown> = {}) {
const now = new Date().toISOString();
return {
id: "graph:ce-plan",
name: "Plan",
description: "",
mode: "prompt" as const,
phase: "pre-merge" as const,
gateMode: "advisory" as const,
prompt: "Plan the work.",
toolMode: "readonly" as const,
enabled: true,
createdAt: now,
updatedAt: now,
...overrides,
};
}
/** captureModifiedFiles / git diff calls go through the mocked execSync→exec. */
function quietGit() {
mockedExecSync.mockImplementation(() => Buffer.from(""));
}
describe("CE workflow-step executor integration", () => {
beforeEach(() => {
resetExecutorMocks();
quietGit();
});
// ── Item 3: synthesized WorkflowStep from a skill graph node ────────────────
describe("runGraphCustomNode skill node (U1/U2)", () => {
it("carries skillName onto the synthesized step AND prepends the conventions preamble", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue(baseStepTask() as any);
const { executor } = makeExecutor(store);
const captured: { step?: any } = {};
vi.spyOn(executor as any, "executeWorkflowStep").mockImplementation(async (...args: any[]) => {
captured.step = args[1];
return { success: true, output: "ok" };
});
const node = {
id: "ce-plan",
kind: "prompt",
column: "review",
config: { executor: "skill", skillName: "compound-engineering:ce-plan", prompt: "Plan the work." },
};
const result = await (executor as any).runGraphCustomNode(node, { id: "FN-CE-1" }, {}, undefined);
expect(result.outcome).toBe("success");
// (U1) skillName threaded onto the step so the session can LOAD it.
expect(captured.step.skillName).toBe("compound-engineering:ce-plan");
// (U2) conventions preamble prepended before the "Invoke the skill" line.
expect(captured.step.prompt).toContain("## Fusion workflow-step conventions");
expect(captured.step.prompt).toContain("===FUSION_AWAIT_INPUT===");
expect(captured.step.prompt).toContain('Invoke the "compound-engineering:ce-plan" skill');
// Original node prompt still present after the preamble.
expect(captured.step.prompt).toContain("Plan the work.");
});
it("a non-skill (model) node synthesizes NO skillName and NO preamble", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue(baseStepTask() as any);
const { executor } = makeExecutor(store);
const captured: { step?: any } = {};
vi.spyOn(executor as any, "executeWorkflowStep").mockImplementation(async (...args: any[]) => {
captured.step = args[1];
return { success: true, output: "ok" };
});
const node = { id: "review", kind: "prompt", column: "review", config: { prompt: "Just review." } };
await (executor as any).runGraphCustomNode(node, { id: "FN-CE-1" }, {}, undefined);
expect(captured.step.skillName).toBeUndefined();
expect(captured.step.prompt).not.toContain("## Fusion workflow-step conventions");
expect(captured.step.prompt).toContain("Just review.");
});
});
// ── Item 5: FUSION_HEADLESS gating on stepEnv ───────────────────────────────
describe("executeWorkflowStep FUSION_HEADLESS (U3)", () => {
it("sets FUSION_HEADLESS=1 only when unattended=true; always sets FUSION_WORKFLOW_STEP", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
// unattended → headless present.
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ skillName: "compound-engineering:ce-plan" }),
"/tmp/wt",
{},
undefined,
{ unattended: true },
);
expect(cap.last?.taskEnv?.FUSION_HEADLESS).toBe("1");
expect(cap.last?.taskEnv?.FUSION_WORKFLOW_STEP).toBe("1");
// board run (default / explicit false) → headless absent, workflow-step still set.
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ skillName: "compound-engineering:ce-plan" }),
"/tmp/wt",
{},
undefined,
{ unattended: false },
);
expect(cap.last?.taskEnv?.FUSION_HEADLESS).toBeUndefined();
expect(cap.last?.taskEnv?.FUSION_WORKFLOW_STEP).toBe("1");
// no stepOptions at all → headless absent.
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ skillName: "compound-engineering:ce-plan" }),
"/tmp/wt",
{},
undefined,
);
expect(cap.last?.taskEnv?.FUSION_HEADLESS).toBeUndefined();
});
it("strips an INHERITED FUSION_HEADLESS on a board run (default-safe invariant)", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
// An outer pipeline exported FUSION_HEADLESS=1 into the inherited env. A board
// run (unattended=false) must NOT inherit it — otherwise the step silently
// skips user questions instead of parking. (PR #1696 review fix.)
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ skillName: "compound-engineering:ce-plan" }),
"/tmp/wt",
{},
{ FUSION_HEADLESS: "1" },
{ unattended: false },
);
expect(cap.last?.taskEnv?.FUSION_HEADLESS).toBeUndefined();
// An explicit unattended opt-in still sets it.
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ skillName: "compound-engineering:ce-plan" }),
"/tmp/wt",
{},
{ FUSION_HEADLESS: "1" },
{ unattended: true },
);
expect(cap.last?.taskEnv?.FUSION_HEADLESS).toBe("1");
});
});
// ── Item 2 (integration half): skillName → requestedSkillNames + paths ───────
describe("executeWorkflowStep skill merge (U1)", () => {
it("merges the step skillName as BOTH namespaced and bare into requestedSkillNames, and threads FUSION_CE_SKILLS_DIR as additionalSkillPaths", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ skillName: "compound-engineering:ce-work" }),
"/tmp/wt",
{},
{ FUSION_CE_SKILLS_DIR: "/opt/ce/.fusion-ce-skills" },
undefined,
);
const requested = cap.last?.skillSelection?.requestedSkillNames ?? [];
expect(requested).toContain("compound-engineering:ce-work");
expect(requested).toContain("ce-work");
// The install root from the injected env becomes the discovery path.
expect(cap.last?.additionalSkillPaths).toEqual(["/opt/ce/.fusion-ce-skills"]);
});
it("a skill-less step contributes no skillName merge and no additionalSkillPaths", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ gateMode: "gate" }), // no skillName
"/tmp/wt",
{},
undefined,
undefined,
);
// No CE skills dir injected → no additionalSkillPaths.
expect(cap.last?.additionalSkillPaths).toBeUndefined();
});
});
// ── Item 4: spawn-tool gating by toolMode ───────────────────────────────────
describe("executeWorkflowStep spawn gating (U8b)", () => {
function toolNames(cap: ReturnType<typeof captureSession>): string[] {
return (cap.last?.customTools ?? []).map((t) => t.name ?? "");
}
it("coding-mode step registers fn_spawn_agent", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ skillName: "compound-engineering:ce-code-review", toolMode: "coding" }),
"/tmp/wt",
{},
undefined,
undefined,
);
expect(toolNames(cap)).toContain("fn_spawn_agent");
});
it("readonly-mode step does NOT register fn_spawn_agent", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ skillName: "compound-engineering:ce-code-review", toolMode: "readonly" }),
"/tmp/wt",
{},
undefined,
undefined,
);
expect(toolNames(cap)).not.toContain("fn_spawn_agent");
});
});
// ── Item 6: verdict-contract conditional ────────────────────────────────────
describe("executeWorkflowStep verdict conditional (KTD-6)", () => {
it("a GATE skill step still requires the trailing verdict JSON (Feedback Format)", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ skillName: "compound-engineering:ce-code-review", gateMode: "gate" }),
"/tmp/wt",
{},
undefined,
undefined,
);
expect(cap.last?.systemPrompt).toContain("## Feedback Format");
expect(cap.last?.systemPrompt).toContain('{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE"');
expect(cap.last?.systemPrompt).not.toContain("## Output Format");
});
it("a skill-LESS prompt step requires the verdict JSON (legacy reviewer contract)", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ gateMode: "advisory" }), // no skillName, advisory
"/tmp/wt",
{},
undefined,
undefined,
);
expect(cap.last?.systemPrompt).toContain("## Feedback Format");
expect(cap.last?.systemPrompt).toContain('{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE"');
});
it("a NON-GATE skill step is RELAXED — Output Format, no required verdict JSON", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ skillName: "compound-engineering:ce-plan", gateMode: "advisory" }),
"/tmp/wt",
{},
undefined,
undefined,
);
expect(cap.last?.systemPrompt).toContain("## Output Format");
expect(cap.last?.systemPrompt).toContain("NOT required to end with a");
expect(cap.last?.systemPrompt).not.toContain("## Feedback Format");
});
});
});

View File

@@ -88,6 +88,12 @@ export interface AgentRuntimeOptions {
skillSelection?: SkillSelectionContext;
/** Convenience: skill names to include in the session */
skills?: string[];
/** Extra directories to scan for skills (each holding `<id>/SKILL.md`), in
* addition to the default cwd/agent-dir roots. Forwarded to the resource
* loader so caller-requested `skills`/`skillSelection` names installed to a
* private dir (e.g. a plugin's bundled-skill root) are discoverable in the
* live session. Mirrors `AgentOptions.additionalSkillPaths` in pi.ts. */
additionalSkillPaths?: string[];
/** Runtime-facing context for non-pi runtimes that cannot consume JS ToolDefinition objects directly. */
runtimeContext?: AgentRuntimeContext;
/**

View File

@@ -978,6 +978,37 @@ export function parseAwaitInputSentinel(output: string | undefined): string | nu
return question ? question : null;
}
/**
* (U2 / KTD-2) Fusion workflow-step conventions preamble, prepended to a skill
* step's prompt at the skill-prompt build path (runGraphCustomNode). It teaches
* any bundled skill the conventions Fusion needs — in ONE engine-side place, so
* the skills stay byte-for-byte upstream. The block is skill-agnostic and rides
* on the node prompt; it deliberately overrides the upstream skill bodies that
* still say "call AskUserQuestion" / "Task ce-*". Stable text — the await-input
* grammar here must match `parseAwaitInputSentinel` and the persona-override
* contract (fn_spawn_agent's `systemPromptOverride` param) verbatim.
*
* (U9 / KTD-7) The persona-fan-out instruction is path-confined: the skill must
* resolve `<persona>.md` strictly within `$FUSION_CE_AGENTS_DIR` and reject any
* `../` traversal before reading, since the file body is injected verbatim into a
* child's system prompt (a filesystem prompt-injection surface otherwise).
*/
export const FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE = `## Fusion workflow-step conventions
You are running as a Fusion autonomous workflow step — NOT an interactive Claude Code session. Follow these conventions; they override any contrary instruction in the skill body below.
1. Asking the user: there is no interactive listener here. \`AskUserQuestion\` / \`request_user_input\` go into the void. When you must ask the user a question, emit EXACTLY ONE block of the form:
===FUSION_AWAIT_INPUT===
<your question for the user>
===END_FUSION_AWAIT_INPUT===
and then STOP. Fusion parks the task awaiting the user's answer and re-runs this step with their reply.
2. Headless runs: when the environment variable \`FUSION_HEADLESS=1\` is set, do NOT ask the user anything. Record a reasonable assumption explicitly in your output and proceed — never emit the await-input block in this mode.
3. Dispatching a \`ce-<persona>\` subagent: do NOT use a raw \`Task ce-*(...)\` call. Instead, read the persona definition from \`$FUSION_CE_AGENTS_DIR/<persona>.md\`, strip its YAML frontmatter, and pass the remaining body as the \`systemPromptOverride\` argument to the \`fn_spawn_agent\` tool. Resolve the path strictly inside \`$FUSION_CE_AGENTS_DIR\` — reject any \`<persona>\` containing \`/\` or \`..\` (path traversal), and skip a def whose body is empty or implausibly large. If \`fn_spawn_agent\` is not available (a readonly step), do the persona's work inline yourself instead of spawning.
`;
/** Result returned from fn_spawn_agent tool */
interface SpawnAgentResult {
agentId: string;
@@ -4021,6 +4052,13 @@ export class TaskExecutor {
* coding/step session runs as a column agent. Cleared in the run's finally. */
private graphColumnAgentResolver = new Map<string, (nodeId: string) => WorkflowColumnAgent | undefined>();
/** (U3) Task ids whose current graph run is genuinely unattended (LFG /
* pipeline / disable-model-invocation — no human will ever answer). Set only
* by an explicit `unattended` workflow-run option; default-absent means a
* board run. runGraphCustomNode reads this to set FUSION_HEADLESS on skill
* steps. Cleared in maybeExecuteWorkflowGraph's finally alongside the resolver. */
private graphUnattendedRuns = new Set<string>();
/** Column-agent seam wiring (column-agent plan U4). The governing graph node id
* for the implementation pass currently in flight for a task — the execute-seam
* prompt node's id (execute seam), or the foreach instance node id (step-execute
@@ -4142,6 +4180,21 @@ export class TaskExecutor {
this.graphColumnAgentResolver.set(task.id, resolveBindingForNode);
}
// (U3) Genuinely-unattended run signal. This is an EXPLICIT opt-in, not an
// inferred heuristic: a run is unattended only when an entrypoint that
// knows no human will ever answer (LFG / pipeline / disable-model-invocation)
// marks it so. No such marker reaches this executor path today (verified —
// KTD-3), so this resolves to false (board run) for every current run, and
// the safe default is preserved: absence of the explicit flag ALWAYS yields
// no FUSION_HEADLESS, so a board task can only ever park (a human can answer
// via the await-input card button), never silently skip approval. When such
// an entrypoint is added, it sets `unattended` here.
// No entrypoint sets this today, so clear any stale entry; a board run never
// sets FUSION_HEADLESS. When an LFG/pipeline/disable-model-invocation
// entrypoint is added, call `this.graphUnattendedRuns.add(task.id)` here and
// the finally below clears it.
this.graphUnattendedRuns.delete(task.id);
const runner = new WorkflowGraphTaskRunner({
store: {
...this.store,
@@ -4245,6 +4298,20 @@ export class TaskExecutor {
}
return true;
} finally {
// FNXC:WorkflowGraph 2026-06-20-23:35:
// Terminate child agents spawned by this graph run's coding-mode skill steps.
// U8 registered fn_spawn_agent for coding-mode steps, but the graph path
// returns from execute() at the graphOwned early-return — BEFORE execute()'s
// outer finally that calls terminateAllChildren. Without this, graph-step
// children orphan their sessions/worktrees, and their ids accumulate in the
// per-parent spawn budget (spawnedAgents[taskId]), starving later steps'
// fan-out (e.g. ce-code-review's reviewer panel). Mirror the non-graph
// cleanup; run it before the per-run graph bookkeeping below.
try {
await this.terminateAllChildren(task.id);
} catch (err) {
executorLog.warn(`terminateAllChildren failed for graph task ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
}
this.graphRouting.delete(task.id);
// Clear per-run step-inversion pins (KTD-8: pinned only for the run's life).
this.graphStepSessionPinned.delete(task.id);
@@ -4252,6 +4319,7 @@ export class TaskExecutor {
// Clear per-run column-agent seam wiring (U4): the resolver and any dangling
// governing-node-id are scoped to this run only.
this.graphColumnAgentResolver.delete(task.id);
this.graphUnattendedRuns.delete(task.id);
this.graphSeamGoverningNodeId.delete(task.id);
// Per-instance keys: clear every instance slot owned by this task.
const ctxPrefix = `${task.id}:`;
@@ -5988,6 +6056,37 @@ export class TaskExecutor {
return false;
}
/** Build the task-scoped runtime env that carries plugin-injected keys
* (e.g. compound-engineering `FUSION_CE_SKILLS_DIR` / `FUSION_CE_AGENTS_DIR`)
* plus the plugin PATH contribution. Shared by the legacy single-session path
* (agentWork, ~7434) and the graph-node skill-step path (runGraphCustomNode,
* U8) so both deliver the same injected env to their sessions. We never mutate
* process.env globally — this scoped env is threaded through taskEnv so session
* subprocesses inherit it without leaking across concurrent tasks. */
private async buildInjectedRuntimeEnv(
taskId: string,
worktreePath: string,
branch: string | undefined,
): Promise<{ env: NodeJS.ProcessEnv; injectedKeyCount: number; pathEntryCount: number }> {
const runtimeEnvContribution = await this.options.pluginRunner?.collectExecutorRuntimeEnv({
taskId,
worktreePath,
rootDir: this.rootDir,
branch,
});
const pathPrepend = runtimeEnvContribution?.pathPrepend ?? [];
const injectedEnv = runtimeEnvContribution?.env ?? {};
return {
env: {
...process.env,
...injectedEnv,
PATH: [...pathPrepend, process.env.PATH ?? ""].filter(Boolean).join(delimiter),
},
injectedKeyCount: Object.keys(injectedEnv).length,
pathEntryCount: pathPrepend.length,
};
}
/** Run a custom (non-seam) graph node on the proven WorkflowStep machinery.
*
* `columnBinding` (plan U3) is the agent binding governing this node's
@@ -6170,7 +6269,10 @@ export class TaskExecutor {
// Agent lookup is best-effort; fall back to the default model.
}
} else if (executorKind === "skill" && typeof cfg.skillName === "string" && cfg.skillName.trim()) {
prompt = `Invoke the "${cfg.skillName}" skill with the following input, following the skill's instructions exactly:\n\n${prompt}`;
// (U2) Prepend the Fusion workflow-step conventions preamble BEFORE the
// "Invoke the skill" line. A skill node always runs as a workflow step here
// (graph path → executeWorkflowStep), so the conventions always apply.
prompt = `${FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE}Invoke the "${cfg.skillName}" skill with the following input, following the skill's instructions exactly:\n\n${prompt}`;
} else if (executorKind === "cli") {
const rawCommand = rawCliCommand;
if (rawCommand) {
@@ -6227,6 +6329,13 @@ export class TaskExecutor {
const mode: "prompt" | "script" = executorKind === "cli" || node.kind === "script" || (node.kind === "gate" && scriptName) ? "script" : "prompt";
const now = new Date().toISOString();
// (U1) Carry the node's skill name onto the synthesized step so the step
// session can actually LOAD it (executeWorkflowStep merges it into the
// resolved skillSelection). Without this, the named skill was only injected
// as prompt text pointing at a skill the session never discovered.
const stepSkillName = executorKind === "skill" && typeof cfg.skillName === "string" && cfg.skillName.trim()
? cfg.skillName.trim()
: undefined;
const step: WorkflowStep = {
id: `graph:${node.id}`,
name: typeof cfg.name === "string" && cfg.name.trim() ? cfg.name : node.id,
@@ -6240,16 +6349,36 @@ export class TaskExecutor {
enabled: true,
createdAt: now,
updatedAt: now,
...(stepSkillName ? { skillName: stepSkillName } : {}),
...(modelProvider && modelId ? { modelProvider, modelId } : {}),
};
// CLI executor passes the node prompt to the named script via env.
const nodeEnv: NodeJS.ProcessEnv | undefined =
executorKind === "cli" && prompt ? { ...process.env, FUSION_NODE_PROMPT: prompt } : undefined;
// (U8a) Thread the plugin-injected runtime env (FUSION_CE_SKILLS_DIR /
// FUSION_CE_AGENTS_DIR + PATH contribution) into prompt-mode skill/model
// steps on the GRAPH path. The legacy single-session caller builds this in
// agentWork; the graph path never did, so skill loading and persona fan-out
// silently no-op'd here. CLI executor keeps its own FUSION_NODE_PROMPT env.
let nodeEnv: NodeJS.ProcessEnv | undefined;
if (executorKind === "cli" && prompt) {
nodeEnv = { ...process.env, FUSION_NODE_PROMPT: prompt };
} else if (mode === "prompt") {
const injected = await this.buildInjectedRuntimeEnv(live.id, worktreePath, live.branch ?? undefined);
nodeEnv = injected.env;
executorLog.log(
`${live.id}: graph node '${node.id}' runtime env injected (${injected.pathEntryCount} PATH entries, ${injected.injectedKeyCount} env keys)`,
);
}
// (U3) Genuinely-unattended signal. `unattended` is an explicit opt-in
// threaded from the workflow-run options (default false = board run, where a
// human can still answer asynchronously via the await-input card button).
// No origin heuristic — absence always yields a board run. executeWorkflowStep
// sets FUSION_HEADLESS=1 only when this is explicitly true.
const unattended = this.graphUnattendedRuns.has(live.id);
const outcome = mode === "script"
? await this.executeScriptWorkflowStep(live, step, worktreePath, settings, nodeEnv)
: await this.executeWorkflowStep(live, step, worktreePath, settings, nodeEnv);
: await this.executeWorkflowStep(live, step, worktreePath, settings, nodeEnv, { unattended });
// Skill-emitted await-input (U6): if the skill asked the user a blocking
// question via the ===FUSION_AWAIT_INPUT=== sentinel, park the task
@@ -7431,24 +7560,10 @@ export class TaskExecutor {
this.activeWorktrees.set(task.id, worktreePath);
executorLog.log(`${task.id}: worktree ready at ${worktreePath}`);
const runtimeEnvContribution = await this.options.pluginRunner?.collectExecutorRuntimeEnv({
taskId: task.id,
worktreePath,
rootDir: this.rootDir,
branch: acquisition.branch ?? undefined,
});
const pathPrepend = runtimeEnvContribution?.pathPrepend ?? [];
const injectedEnv = runtimeEnvContribution?.env ?? {};
// We intentionally do NOT mutate process.env globally. This task-scoped env is
// passed through AgentRuntimeOptions so executor session subprocesses inherit it
// without leaking across concurrent tasks.
taskEnv = {
...process.env,
...injectedEnv,
PATH: [...pathPrepend, process.env.PATH ?? ""].filter(Boolean).join(delimiter),
};
const injected = await this.buildInjectedRuntimeEnv(task.id, worktreePath, acquisition.branch ?? undefined);
taskEnv = injected.env;
executorLog.log(
`${task.id}: executor runtime env injected (${pathPrepend.length} PATH entries, ${Object.keys(injectedEnv).length} env keys)`,
`${task.id}: executor runtime env injected (${injected.pathEntryCount} PATH entries, ${injected.injectedKeyCount} env keys)`,
);
this.options.onStart?.(task, worktreePath);
@@ -12412,8 +12527,13 @@ ${failureFeedback}
worktreePath: string,
settings: Settings,
taskEnv?: NodeJS.ProcessEnv,
stepOptions?: { unattended?: boolean },
): Promise<WorkflowStepOutcome> {
const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly";
// (U3) Genuinely-unattended run — set FUSION_HEADLESS=1 below so skills record
// assumptions and proceed instead of parking on a question. Explicit opt-in
// only (default false = board run); see runGraphCustomNode / KTD-3.
const unattended = stepOptions?.unattended === true;
// Compute the diff scope so the workflow step agent reviews only what THIS
// task changed — not unrelated files it might wander into. Without this,
@@ -12450,6 +12570,42 @@ CRITICAL SCOPING RULES — read before doing anything else:
- If NONE of the files in the diff scope are relevant to your review category (e.g. a UX/design reviewer with no UI/CSS/component files in scope, a security reviewer with no auth/network code in scope, an a11y reviewer with no markup changes), respond IMMEDIATELY with a single short approval line such as "No relevant changes in scope — approved." and STOP. Do not start exploring the codebase.
- Your wall-clock budget is short. Spending it browsing unmodified files will cause this step to time out and block merge.`;
// (KTD-6) Verdict-contract reconciliation. The trailing-verdict JSON is the
// gate-parsing contract — it only matters for steps that gate merge. A skill
// step that isn't a gate (e.g. ce-plan / ce-work / ce-compound) produces
// skill-native output (and may emit a ===FUSION_AWAIT_INPUT=== sentinel and
// stop), so forcing a verdict would contradict the U2 preamble. Require the
// verdict only for gate steps (and skill-less prompt steps, which keep the
// legacy reviewer contract); relax it for non-gate skill steps. The executor
// runs parseAwaitInputSentinel on output regardless, so the await-input
// sentinel always takes priority when present.
const isSkillStep = typeof workflowStep.skillName === "string" && workflowStep.skillName.trim().length > 0;
const requireVerdict = workflowStep.gateMode === "gate" || !isSkillStep;
const verdictBlock = requireVerdict
? `
## Feedback Format
When your review is complete, your final line MUST be a single JSON object (no markdown fences):
{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}
Rules:
- Output exactly one trailing JSON object and stop.
- verdict must be exactly APPROVE, APPROVE_WITH_NOTES, or REVISE.
- notes should be concise and actionable. Use an empty string when there are no notes.
- For out-of-scope fast-bail responses, use: {"verdict":"APPROVE","notes":"out of scope: no UI files changed"}
Backward compat fallback: if JSON is unavailable, you may still begin output with REQUEST REVISION to request changes.`
: `
## Output Format
Follow the skill's own output conventions. You are NOT required to end with a
verdict JSON object — this step does not gate merge. If you need to ask the user
a question, emit a single ===FUSION_AWAIT_INPUT=== block and stop (see the
workflow-step conventions in your instructions).`;
const systemPrompt = `You are a workflow step agent executing: ${workflowStep.name}
Task Context:
@@ -12467,21 +12623,7 @@ Your role:
Your Instructions:
${workflowStep.prompt}
You have access to the file system to review changes.
## Feedback Format
When your review is complete, your final line MUST be a single JSON object (no markdown fences):
{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}
Rules:
- Output exactly one trailing JSON object and stop.
- verdict must be exactly APPROVE, APPROVE_WITH_NOTES, or REVISE.
- notes should be concise and actionable. Use an empty string when there are no notes.
- For out-of-scope fast-bail responses, use: {"verdict":"APPROVE","notes":"out of scope: no UI files changed"}
Backward compat fallback: if JSON is unavailable, you may still begin output with REQUEST REVISION to request changes.`;
You have access to the file system to review changes.${verdictBlock}`;
const agentLogger = new AgentLogger({
store: this.store,
@@ -12548,12 +12690,86 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
// convention (which the dashboard / task card renders) instead of calling
// AskUserQuestion into the void. Scoped to the step session — the main
// executor session deliberately does not carry it.
// (FUSION_HEADLESS is reserved for a future genuinely-unattended run signal —
// LFG/pipeline — where no human can answer even asynchronously.)
const stepEnv: NodeJS.ProcessEnv = { ...(taskEnv ?? process.env), FUSION_WORKFLOW_STEP: "1" };
// (U3) FUSION_HEADLESS=1 marks a genuinely-unattended run (LFG/pipeline) so
// skills record assumptions and proceed instead of parking. Set ONLY when
// the explicit `unattended` flag is true; absent on a board run.
const stepEnv: NodeJS.ProcessEnv = {
...(taskEnv ?? process.env),
FUSION_WORKFLOW_STEP: "1",
};
// FNXC:WorkflowSteps 2026-06-21-06:30:
// Default-safe invariant (KTD-3): a board run must NEVER be headless. Since
// stepEnv spreads taskEnv/process.env, an inherited FUSION_HEADLESS (e.g. an
// outer pipeline exported it) would otherwise leak in and silently skip user
// questions. Set it ONLY on an explicit opt-in; strip any inherited value
// otherwise so absence of the flag always yields a board run.
if (unattended) {
stepEnv.FUSION_HEADLESS = "1";
} else {
delete stepEnv.FUSION_HEADLESS;
}
// (U1) Load the step's named skill into THIS session. The interactive fix
// proved the resolver works when fed BOTH a requested name AND a discovery
// path (compound-engineering-skill-resolution.test.ts). Here we mirror it:
// merge the step's skillName (both namespaced `compound-engineering:ce-work`
// and bare `ce-work` — the resolver matches bare names case-insensitively)
// into the resolved requestedSkillNames, and pass the CE install root (from
// the injected FUSION_CE_SKILLS_DIR env) as additionalSkillPaths so the
// loader can actually discover the bundled SKILL.md. Without both halves the
// named skill was only prompt text pointing at a skill the session never had.
let effectiveSkillSelection = skillContext.skillSelectionContext;
const ceSkillsDir = typeof stepEnv.FUSION_CE_SKILLS_DIR === "string" && stepEnv.FUSION_CE_SKILLS_DIR.trim()
? stepEnv.FUSION_CE_SKILLS_DIR.trim()
: undefined;
if (workflowStep.skillName && workflowStep.skillName.trim()) {
const namespaced = workflowStep.skillName.trim();
const bare = namespaced.includes(":") ? namespaced.slice(namespaced.lastIndexOf(":") + 1) : namespaced;
const existing = effectiveSkillSelection?.requestedSkillNames ?? [];
const mergedNames = [...new Set([...existing, namespaced, bare])];
effectiveSkillSelection = {
projectRootDir: effectiveSkillSelection?.projectRootDir ?? this.rootDir,
...(effectiveSkillSelection?.sessionPurpose ? { sessionPurpose: effectiveSkillSelection.sessionPurpose } : { sessionPurpose: "executor" }),
requestedSkillNames: mergedNames,
};
}
// FNXC:WorkflowSteps 2026-06-20-23:35:
// A named skill with no discovery path silently degrades to the role-fallback
// skill (the exact pre-fix bug this change exists to kill). If the injected
// FUSION_CE_SKILLS_DIR never arrived (degraded/throwing plugin, missing install
// dir), warn loudly so an env-threading regression is visible on a board run
// instead of failing silent with a green hand-fed test.
if (workflowStep.skillName && workflowStep.skillName.trim() && !ceSkillsDir) {
await this.store.logEntry(
task.id,
`[skill-load] Workflow step '${workflowStep.name}' requests skill '${workflowStep.skillName}' but FUSION_CE_SKILLS_DIR is unset — the skill cannot be discovered; the step runs with role-fallback skills only.`,
);
}
const additionalSkillPaths = ceSkillsDir ? [ceSkillsDir] : undefined;
// (U8b) Coding-mode skill steps fan out to ce-<persona> subagents via
// fn_spawn_agent (read the persona def, pass its body as systemPromptOverride).
// That tool is registered only in the main executor session — never here —
// so coding mode granted write/edit but NOT spawn. Register it for
// coding-mode steps now; readonly steps keep no spawn (filterCustomToolsForReadonly
// strips it). The spawn tool inherits the injected env so children also see
// FUSION_CE_AGENTS_DIR.
//
// (U9 / KTD-4, Risk-1) ACCEPTED WRITE-CAPABILITY POSTURE: coding mode also
// exposes write/edit. The CE plan/code-review steps run coding ONLY to gain
// spawn (they are not supposed to mutate the tree), but the tool policy is
// binary today — coding is the only mode that carries fn_spawn_agent. There
// is NO engine guard preventing those steps from writing; the only protection
// is skill discipline plus the U6 no-diff detection assertion. The proper fix
// (a dedicated readonly-plus-spawn tool mode) is deferred; this is a
// knowingly-accepted gap, not a closed one — re-evaluate before enabling the
// CE workflow for genuinely-unattended (FUSION_HEADLESS) LFG/pipeline runs.
const codingCustomTools: ToolDefinition[] = toolMode === "coding"
? [this.createSpawnAgentTool(task.id, worktreePath, settings, stepEnv)]
: [];
const readonlyCustomTools = toolMode === "readonly"
? filterCustomToolsForReadonly([])
: { allowed: [] as ToolDefinition[], denied: [] as string[] };
? filterCustomToolsForReadonly(codingCustomTools)
: { allowed: codingCustomTools, denied: [] as string[] };
if (toolMode === "readonly" && readonlyCustomTools.denied.length > 0) {
await this.store.logEntry(
task.id,
@@ -12576,8 +12792,10 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)),
settings,
taskEnv: stepEnv,
// Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
// Skill selection: assigned-agent / role-fallback skills, plus the step's
// own named skill (U1) made discoverable via additionalSkillPaths.
...(effectiveSkillSelection ? { skillSelection: effectiveSkillSelection } : {}),
...(additionalSkillPaths ? { additionalSkillPaths } : {}),
...(readonlyCustomTools.allowed.length > 0 ? { customTools: readonlyCustomTools.allowed } : {}),
});
@@ -15214,6 +15432,16 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
// A non-empty systemPromptOverride lets the caller run the child as a
// specific persona (e.g. a compound-engineering reviewer) instead of the
// generic child executor. Executor instructions are still appended below.
//
// (U9 / KTD-7) The engine does NOT itself resolve the persona def file —
// the calling skill reads `$FUSION_CE_AGENTS_DIR/<persona>.md` (the
// FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE instructs a path-confined
// read: confined to the install dir, `../` rejected, body-size sanity
// checked) and passes the stripped body here. The override body is
// therefore trusted only to the extent that read was confined; the
// agents dir is plugin-installer-owned and lives OUTSIDE the task
// worktree (so coding-mode plan/code-review steps can't write into it —
// see assertPluginLocalAgentsTarget in the CE plugin installer).
const personaOverride = systemPromptOverride?.trim();
const childBasePrompt = personaOverride
? `${personaOverride}