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 batchThis 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 guaranteedTwo 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 batchfeature-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 batchAPI
| Member | What 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=v → flags.k, --flag → true, -h/--help → flags.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):
| Generator | Shows | Run |
|---|---|---|
feature-rollout.js | Raw JSON (no helper), per-module fan-out, a conditional security audit for sensitive modules, an approval checkpoint gating ship | node feature-rollout.js auth-v2 api ui payments --approver=alice | clu batch |
release-train.js | The clu.js helper: fan-out → fan-in checkpoint → fan-out, needs passed by handle | node release-train.js v1.4.0 api worker web --approver=alice | clu batch |
migrate.js | phase() (auto-advancing milestone boundaries) + a checkpoint in the final phase | node migrate.js solid api ui store | clu batch --group "React→Solid" |
linear-todo.js | Turning a Linear export into a graph with idempotent keys — see Importing | linear 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 in | YAML in .clu/templates/ | A script in any language |
| Shape | Fixed; {{var}} substitution | Computed: loops, conditionals, fan-out |
| Best for | A stable, repeatable process | A 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.