cyberine
UsageRun recipes

6. Workflows + live fleet view (the orchestration layer)

Run a multi-step workflow - a DAG of tasks where each step fans out to all available adapters, gets judged, and its winning output flows into dependent steps via {{stepId}} templating:

npm run wf -- --sample                          # built-in 2-step demo (answer -> critique)
npm run wf -- src/engine/sampleDagWorkflow.ts   # branching diamond (start -> pros/cons -> verdict)
npm run wf -- path/to/def.ts                    # your own workflow (default-exports a WorkflowDef)
npm run wf -- path/to/script.ts                 # imperative SCRIPT (exports meta + a default async body)

npm run wf accepts two shapes. A DAG module default-exports a WorkflowDef (static steps, judged, templated). An imperative SCRIPT module instead exports a meta object plus a default async function that receives a host context and drives the fleet by hand:

import type { WorkflowScriptMeta, HostContext } from './src/engine/workflowScript.ts';
export const meta: WorkflowScriptMeta = { name: 'flow-demo', description: 'imperative example' };
export default async function (host: HostContext) {
  host.phase('Work');
  const a = await host.agent('answer X');                 // single-pick worker -> text
  const obj = await host.agent('return JSON', { schema }); // { schema } -> parsed + validated object
  const xs = await host.parallel([() => host.agent('a'), () => host.agent('b')]); // concurrent
  host.log(`spent ${host.budget.spent()} / ${host.budget.total ?? 'unbounded'} tokens`);
}

Host helpers: agent(prompt, opts?) (single-pick; opts.schema returns a validated object, opts.model/ opts.agentType force a worker), parallel(thunks), pipeline(items, ...stages), phase(title), log(msg), args, and budget (total/spent()/remaining()). Concurrency is capped by a semaphore (min(16, cores-2)); set a token ceiling with CYBERINE_WF_BUDGET (agent() stops running once it is hit). workflow-cli auto-detects the shape (a meta + default-function module routes to the script runtime; a WorkflowDef default export routes to the DAG engine).

Per-step model override (DAG defs). A WorkflowStep accepts an optional flat model field that overrides the dispatched loop worker's model for that step only — a cheap model for grunt steps, a stronger one for a gated verify step. It flows through Task.modelOverride into LoopAgentAdapter (CLI workers ignore it); omit it and the worker's default model is unchanged.

const def: WorkflowDef = {
  name: 'tiered',
  steps: [
    { id: 'draft',  prompt: '...', model: 'deepseek-v4-flash' }, // cheap grunt
    { id: 'verify', prompt: '...', model: 'claude-opus-4-8', deps: ['draft'] }, // strong gate
  ],
};

Runs launched via the script runtime are persisted to RunStore with source: "workflow-cli" and registered in the global registry, so they appear live in cyberine agents / npm run tui alongside DAG-engine runs — no separate monitoring needed.

Known Limitations and Deferred Work (script runtime, src/engine/WorkflowScriptRuntime.ts). host.agent() is single-pick per call — no DiffJudge race across candidates, deliberately out of scope for this runtime's first pass. host.workflow() (calling one script from another) is capped at nesting depth 1 — a script cannot itself call a script that calls a script. Journaled resume (npm run wf -- <script> --resume <runId>) replays a prior host.agent() call only when its call index and its prompt+opts hash both match the journal exactly; a mismatch is not surfaced as a warning, it just re-runs that call (and every call after it) as if nothing was cached — so editing the script upstream of a resumed run can silently invalidate the whole replay.

Memory consolidation — retired

src/workflows/dream-consolidate.ts (flow-dream-consolidate) was RETIRED on 2026-09-09 (k0wf7, operator decision). It was a June port of the operator's private Claude Code flow-dream flow, decision-only (its write phases were never built), never run outside three smoke runs, and already a different design from the batched read-rewrite engine the operator rewrote in July -- two engines with one name cleaning one memory tree two ways. Cleaning ~/.claude/memory is Claude Code tooling's job. Cyberine's own memory is ~/.cyberine/memory/ (cyberine memory list|show|search|chain, the Long-term memory section below) and stays vendor-independent, so the same orchestrator serves Codex or any other CLI without depending on one vendor's memory layout. src/workflows/ and the .cyberine/workflows/flow-dream-consolidate.ts shim were removed with it.

YAML recipes (T323) — portable, parameterized, nestable

The same engine accepts a YAML file as a third shape (alongside the DAG module and the imperative script). The loader (src/engine/yamlRecipe.ts) parses the file, materialises a WorkflowDef, and feeds it to the engine unchanged — the YAML surface is a strict subset of the TS surface plus a few recipe-only conveniences (params, env defaults, extends).

Run recipe

npm run wf -- examples/recipes/sample-research.yaml
npm run wf -- examples/recipes/sample-research.yaml --param topic="Rust async runtimes"
npm run wf -- examples/recipes/sample-research.yaml --param topic="Bun vs Deno" --param max_questions=2
# Saved-name form (works when the file is in <repo>/.cyberine/workflows/ or ~/.cyberine/workflows/):
cyberine wf run sample-research --param topic="DuckDB vs SQLite"

The runner auto-detects the shape by file extension: .yaml / .yml go through the recipe loader; .ts files keep their existing DAG / script dispatch. The same --param key=value flag is forwarded by cyberine wf run <name> --param ... so a saved name plus overrides works the same as a path.

Extend recipe — recipe shape

extends: ./base.yaml              # optional; parent recipe (path relative to this file)
name: my-recipe                   # surfaced in the run record
description: one-line summary     # not consumed by the engine
params:                           # parameter declarations with defaults
  topic: TypeScript
  depth: 2
  prompt_style: { default: terse, description: "controls tone" }
env:                              # process.env defaults (never clobber an existing var)
  LOG_LEVEL: info
steps:
  - id: plan
    title: Pick a research goal
    prompt: |
      Pick a question about ${params.topic} with ${params.depth} angles.
  - id: research
    title: Web research fan-out
    depends_on: [plan]
    workers: [opencode, loop-deepseek-direct]
    search_fan_out:
      questions:
        - "How does ${params.topic} compare to alternatives?"
  - id: verify
    title: Verify findings
    depends_on: [research]
    verify_claims: { claims_from: research, skeptics: 2 }
  - id: report
    title: Synthesize a cited report
    depends_on: [verify]
    synthesize_claims: { verified_from: verify }

Substitution tokens (resolved at materialisation, before the engine sees the def):

TokenSource
${params.X}recipe parameter (override or default)
${defaults.X}alias for params.X
${env.X}process.env[X] after env: defaults applied
${anything.else}passes through unchanged (so ${VAR} survives in shell snippets)

The engine's own {{stepId}} placeholder is left intact by the loader — recipe substitution runs first, then the engine substitutes the winning outputs of completed steps. So a YAML step prompt can mix both: Pick a question about ${params.topic} using this prior answer: {{plan}}.

Nesting — extends: <relative-yaml> loads the parent (cycle-guarded), concatenates parent steps first then child steps, and overlays the child's params on the parent's. A child override reaches into the parent's step prompts because substitution runs on the merged step list + merged params. The child may add new steps that depend on parent steps via depends_on: [parent_step_id]. A reused step id is a hard error.

Available step fields mirror the TS WorkflowStep type: id, title, prompt, depends_on, workers, isolate, verify_cmd, swarm_use_lane_runner, swarm_chunks, search_fan_out, verify_claims, synthesize_claims, await_run, projection. Snake-case keys map to the camelCase TS fields internally; the engine is never modified.

Save under a short name to use cyberine wf run <name> form:

cp examples/recipes/sample-research.yaml .cyberine/workflows/research.yaml   # project-local
cp examples/recipes/sample-research.yaml ~/.cyberine/workflows/research.yaml # global
cyberine wf run research --param topic="TypeScript vs Rust"

Each run is persisted under .local/runs/<runId>/ (gitignored):

FileContents
run.jsonfull run state tree: steps -> workerRuns (state / tempo / detail / usage / output)
events.jsonlappend-only lifecycle stream (run:start, step:start, worker:start, progress, worker:done, step:done, run:done)
<step>__<agent>.outputper-worker streamed output (live tail-able from the TUI)

Then watch runs live in the terminal fleet view (modelled on claude agents):

npm run tui

The header is a breadcrumb trail (Cyberine Fleet › <workflow> › <worker>) so you always know how deep you are. Press ? on any screen for the full keybinding overlay — it shows the keys you have actually bound (from CYBERINE_KEYBINDINGS / ~/.cyberine/keybindings.json), not hard-coded defaults, so a remapped quit/help/inspect/new-session key is reflected there. See "Keybindings — remap fleet + chat actions" below for the full schema and action table (both cyberine agents and cyberine chat share one file, one namespace per app).

Since k534j the footer hint bar and that ? overlay are DERIVED from one action registry (src/tui/fleetActions.ts) instead of three hand-maintained copies, so remapping a fleet.* key moves the handler, the footer hint and the help row together — and a help row can no longer name a key that does something else. npm run selftest:fleet-action-registry guards it (each remap check carries a positive control, so a derivation that degenerated into a constant fails).

Keys (arrows or vim hjkl):

  • List: ↑↓/jk navigate runs, Ctrl+D/Ctrl+U half-page jump, g/G top/bottom, ⏎/→/l inspect, / filter the list (persistent; Esc clears), f fuzzy-jump to a run (moves the selection, keeps no filter), x stop the run (the fleet.stop binding), Ctrl+X Ctrl+K stop EVERY running agent (a chord — Ctrl+X only arms it), s cycle the landing view (hub / sessions / fleet), o open the selected run's folder, ? help, q quit. Since kfw8v each of these has exactly ONE owner: Ink runs every mounted handler on every keypress, so s used to both cycle the view and stop a run, and / used to open two filtering surfaces at once.
  • Run detail: ↑↓/jk select a worker, ⏎/→/l peek, p toggle the full step prompt, d open the winning diff, s stop the run, x stop the selected worker, esc/←/h back.
  • Peek: Ctrl+U/Ctrl+D scroll the output (g top, G follow live), s stop this worker, esc/←/h back, ? help, q quit.
  • Diff viewer: ↑↓/jk scroll, Ctrl+U/Ctrl+D page, g/G top/bottom, esc/←/h/q back.