clu

Dynamic workflows (JS)

Generate issue graphs in code — loops, conditionals, computed fan-out — and pipe them into clu batch. The programmable alternative to static YAML templates.

A clu run template is a fixed shape with {{var}} substitution. When the shape itself is computed — a step per module, an audit only for sensitive services, fan-out sized by an argument — write the graph in real code and pipe it into clu batch:

node feature-rollout.js auth-v2 api ui payments --approver=alice | clu batch

This is clu's "codemode": the generator is any program that prints a JSON graph; clu batch validates it (acyclic, every reference resolves, fields valid) and writes it in one transaction.

  generation (your script)          →     instantiation (clu batch)
  any language, any logic                 validates + writes one graph, one tx
  loops · conditionals · fan-out          "graphed correctly" is guaranteed

Two layers, one contract — JSON. The generator decides the shape; clu batch owns all validation, so a generator can never write a malformed or half-built graph. See Importing & bulk graphs for the document format, idempotent keys, and --group/--dry-run.

It's just JSON

The minimal generator is anything that prints an array of {alias, title, …} objects. No library required — here's the whole contract in plain Node:

const issues = [
  { alias: 'design', title: 'Design auth', type: 'decision', priority: 1 },
  { alias: 'impl', title: 'Implement auth', needs: ['design'], capabilities: ['go'] },
  { alias: 'gate', title: 'Approve release', needs: ['impl'], checkpoint: { approvers: ['alice'] } },
  { alias: 'ship', title: 'Ship', needs: ['gate'] },
]
process.stdout.write(JSON.stringify(issues) + '\n')   // → | clu batch

feature-rollout.js is written exactly this way (raw objects, no helper) as the "translate to any language" reference — Python, Ruby, a shell jq pipeline all work the same.

The clu.js helper

For Node generators, examples/generators/clu.js is a tiny zero-dependency convenience — copy it or import it. It only shapes data (so it can't drift from the real validator); the one thing it checks locally is alias uniqueness, thrown at add() time with a stack trace into your code instead of a batch-time error.

import { Graph } from './clu.js'

const g = new Graph()
const design = g.add('design', { title: 'Design', type: 'decision' })
const impl = g.add('impl', { title: 'Implement', needs: [design] }) // wire by handle
g.checkpoint('gate', { title: 'Approve', needs: [impl], approvers: ['alice'] })
g.add('ship', { title: 'Ship', needs: ['gate'] })
g.emit() // writes one JSON document to stdout → | clu batch

API

MemberWhat it does
new Graph()Start an empty graph.
g.add(alias, fields)Append an issue; returns its alias so you pass the return value straight into another issue's needs (a typo becomes a JS reference error, not a batch-time "unknown ref"). fields is any subset of the issue shape: title, type, priority, assignee, description, notes, capabilities, labels, needs.
g.checkpoint(alias, fields)add() for a manual approval gate. Pass approvers: [...] for an approval checkpoint, omit for a manual one (anyone can clear it).
g.phase(name, fn)Group every issue added inside fn() into an ordered stage (see below). Returns the phase's gate alias.
g.emit(stream?)Write the batch document (one JSON value) to stdout (default).
g.toJSON() / g.issues()Get the raw array to inspect or post-process before emitting.
parseArgs(argv?)Split args into { flags, positional }--k=vflags.k, --flagtrue, -h/--helpflags.help.
usage(text, code?)Print to stderr and exit (code 0 for --help, non-zero for a bad invocation).

Phases

phase(name, fn) groups the issues added inside fn() into an ordered stage: a phase can't start until the previous one is fully done. The helper drops a milestone at each boundary that auto-closes when its phase completes, unblocking the next phase — no human gate (use checkpoint() inside a phase for that). Each task is labelled phase:<n>-<name>.

const g = new Graph()
g.phase('inventory', () => { for (const m of mods) g.add(`inv-${m}`, { title: `Inventory ${m}` }) })
g.phase('analysis', () => { for (const m of mods) g.add(`ana-${m}`, { title: `Analyze ${m}` }) })
g.phase('migrate', () => { for (const m of mods) g.add(`mig-${m}`, { title: `Migrate ${m}` }) })
g.emit()

Pair with clu batch --group "<name>" for an umbrella that itself self-completes when the whole thing is done.

Worked examples

All four live in examples/generators/ and run with plain node (they're ES modules):

GeneratorShowsRun
feature-rollout.jsRaw JSON (no helper), per-module fan-out, a conditional security audit for sensitive modules, an approval checkpoint gating shipnode feature-rollout.js auth-v2 api ui payments --approver=alice | clu batch
release-train.jsThe clu.js helper: fan-out → fan-in checkpoint → fan-out, needs passed by handlenode release-train.js v1.4.0 api worker web --approver=alice | clu batch
migrate.jsphase() (auto-advancing milestone boundaries) + a checkpoint in the final phasenode migrate.js solid api ui store | clu batch --group "React→Solid"
linear-todo.jsTurning a Linear export into a graph with idempotent keys — see Importinglinear issue query --json | node linear-todo.js | clu batch

The conditional logic a static template can't express — from feature-rollout.js:

const SENSITIVE = /pay|auth|billing|account|secret|token|crypto/i
for (const mod of modules) {
  const impl = add({ alias: `impl-${mod}`, title: `Implement ${feature} — ${mod}`, needs: ['design'] })
  add({ alias: `test-${mod}`, title: `Test ${mod}`, needs: [impl] })
  if (SENSITIVE.test(mod)) {                                   // ← only some modules
    add({ alias: `audit-${mod}`, title: `Security audit ${mod}`, priority: 0, needs: [impl] })
  }
}

Iterate safely

Validate the generator with --dry-run (stats only, writes nothing); commit with --json to get the alias → real-id map back:

node release-train.js v1.4.0 api worker web --approver=alice | clu batch --dry-run
# valid: 8 issues, 10 edges, 3 roots, 3 leaves, depth 3, 1 checkpoints

node release-train.js v1.4.0 api worker web --approver=alice | clu batch --json | jq '.created'

Dynamic vs static

Static template (clu run)Dynamic generator (… | clu batch)
Defined inYAML in .clu/templates/A script in any language
ShapeFixed; {{var}} substitutionComputed: loops, conditionals, fan-out
Best forA stable, repeatable processA shape that depends on its input
Checkpoints
Lives in the repo✅ (committed YAML)✅ (committed script)

Use a template when the steps never change; reach for a generator the moment the graph depends on what you feed it.

On this page