clu

Importing & bulk graphs

Turn one JSON document into a whole validated issue graph with clu batch — and pipe issues in from a Linear or Notion CLI.

Many data streams converging through a funnel into one dependency graph

clu batch instantiates an entire issue graph — issues, dependencies, labels, checkpoints — from a single JSON document, in one transaction. It's how you bulk-load work: a generated plan, a migration broken into phases, or issues pulled from another tracker.

generate-the-graph | clu batch

The producer can be anything that emits JSON — a shell script, a Node generator, a jq transform over another CLI's output. clu validates the graph (acyclic, every reference resolves, every field valid), allocates real IDs, and writes it atomically. A single bad entry aborts the whole batch, so you never get a half-built graph.

Why this split

There are two distinct jobs, and clu batch only owns the second:

  • Generationany language, any logic: loops, conditionals, computed fan-out, reading an API to decide the shape. clu doesn't care what produced the graph.
  • Instantiation (clu batch) — validation, ID allocation, and one atomic write. "Graphed correctly" is guaranteed here, not hoped for in the generator.

This is the programmable alternative to a static clu run YAML template. Use a template for a fixed, repeatable shape; use batch when the shape is computed.

The document format

Pass either a bare array of issues or an object with an optional group:

// bare array
[ { "alias": "a", "title": "…" }, { "alias": "b", "title": "…", "needs": ["a"] } ]

// or wrapped, with an umbrella parent
{ "group": "Auth rollout", "issues": [  ] }

Only alias and title are required per issue. The full field set:

fieldtypemeaning
aliasstringLocal handle, unique within the batch. Wires needs; not stored.
titlestringThe issue title.
typestringtask (default), bug, feature, epic, chore, decision, checkpoint, milestone.
priorityint0 (highest) … 4 (lowest). Default 2.
assigneestringPre-route to an agent lane (issue stays open).
description / notesstringFreeform text.
capabilities[string]Stored as cap:<name> routing labels (see capability routing).
labels[string]Arbitrary extra labels.
needs[string]Dependencies — each is either another issue's alias (internal edge) or an existing real ID like clu-a1b2c3 (external edge onto the committed graph).
keystringStable external identity, e.g. linear:ENG-123. Makes re-runs idempotent (see below). Unique within the batch.
checkpointobjectMakes this a manual gate: {} (anyone may clear) or {"approvers":["alice"]}. Forces type: checkpoint.

Unknown fields are rejected — a typo like capabilites fails loudly instead of silently dropping data. Run clu batch --docs for the same reference from the CLI.

Validate before you commit

--dry-run validates and prints graph stats without writing anything. It reports every problem at once (missing refs, cycles, bad fields, duplicate aliases or keys) so a generator can be fixed in a single pass:

generate-the-graph | clu batch --dry-run
# valid: 124 issues, 130 edges (2 external), 8 roots, 11 leaves, depth 5, 3 checkpoints

Commit with --json to get the alias → real-id map back:

generate-the-graph | clu batch --json | jq '.created'
# { "design": "clu-a1b2c3", "impl": "clu-d4e5f6", … }

Umbrellas with --group

--group "Title" wraps the whole batch under a self-completing parent (a milestone that auto-closes when every child closes). Every issue and the parent get a run:<parent-id> label, so the batch is addressable as a unit:

generate-the-graph | clu batch --group "Q3 migration"
clu list -l run:clu-parent99        # everything in that batch

A group field in the document does the same; the --group flag wins if both are present.

Idempotent re-import (key + --on-existing)

Give an issue a stable key and re-running the batch won't duplicate it. clu records the key (as an extkey:<key> row) on first create; on later runs it matches by key and, per --on-existing:

  • skip (default) — the existing issue is left untouched; only genuinely-new issues are added.
  • update — the issue's source-owned fields (title, type, priority, description) are re-synced; local workflow state (status, assignee, labels, deps, notes) is left alone.

Either way the alias still resolves to the existing ID, so a new issue that needs an already-imported one wires up correctly. --json reports new / existing / updated counts.

--group creates a new umbrella on every run (the parent isn't keyed). Don't pass it on a scheduled re-import unless you want one umbrella per run.

Piping from another tracker

The contract is just JSON, so any tracker with a CLI or API can feed clu batch. The recipe is always the same: dump → map to the batch shape (give each issue a stable key) → pipe.

Linear

A ready-made generator lives at examples/generators/linear-todo.js. It reads Linear issues as JSON on stdin, maps Linear's priority scale to clu's, and tags each issue with a linear:<id> key:

linear issue query --all-teams --state unstarted --json --limit 0 --no-pager \
  | node examples/generators/linear-todo.js \
  | clu batch --group "Linear import"

# idempotent re-run — re-sync titles/priorities from Linear:
linear issue query --state unstarted --json \
  | node examples/generators/linear-todo.js \
  | clu batch --on-existing update

Notion

No generator needed — a jq transform turns a Notion database query into the batch shape. Map the page id to a notion:<id> key so re-runs stay idempotent:

ntn db query <database-id> --json \
  | jq '[.results[] | {
        key:   ("notion:" + .id),
        alias: ("ntn-" + (.id | gsub("[^a-zA-Z0-9]"; "-"))),
        title: (.properties.Name.title[0].plain_text // "Untitled"),
        priority: (if .properties.Priority.select.name == "High" then 1 else 2 end),
        labels: ["notion"]
      }]' \
  | clu batch --group "Notion import" --on-existing update

Adjust the property paths to your Notion schema and whichever notion/ntn CLI you use — only the output shape matters to clu.

Anything else

GitHub issues, a CSV, a JIRA export — same pattern. Produce a JSON array where each element has at least alias + title (and ideally a key), and pipe it in. For wholesale moves between clu databases, prefer the export/import round-trip below.

The clu.js helper (optional)

For Node generators, examples/generators/clu.js is a tiny zero-dependency helper. It only shapes data — clu batch still owns all validation, so it can't drift from the real rules. The wins are ergonomic: add() returns the alias so you wire needs by handle (typos become JS reference errors), and duplicate aliases throw at build time.

import { Graph } from "./clu.js";

const g = new Graph();
const design = g.add("design", { title: "Design auth", type: "decision" });
const impl   = g.add("impl",   { title: "Implement auth", needs: [design], capabilities: ["go"] });
g.checkpoint("gate", { title: "Approve release", needs: [impl], approvers: ["alice"] });
g.add("ship", { title: "Ship", needs: ["gate"] });
g.emit();                            // one JSON document on stdout → | clu batch

Graph.phase(name, fn) groups issues into ordered stages: a phase can't start until the previous one finishes. The helper drops a milestone at each boundary that auto-closes when its phase completes, unblocking the next — no human gate (use checkpoint() inside a phase for that). See the runnable generators in examples/generators/: feature-rollout.js, release-train.js, migrate.js, linear-todo.js.

Backup & restore

clu batch is for constructing graphs. To move a whole tracker verbatim between databases, use the JSONL round-trip — it carries issues, deps, comments, KV, and cron, preserving IDs:

clu export -o backup.jsonl          # dump everything as JSONL
clu import backup.jsonl             # restore into another .clu (one transaction)

import is the inverse of export; --lenient skips unparseable lines. Reach for batch when you're creating work, export/import when you're relocating it.

For an ongoing, git-native version of the same round-trip — store the tracker on a branch-independent refs/clu/store ref and reconcile it across clones with last-writer-wins + tombstones — see Sync (git ref). state.jsonl on that ref is exactly this export format; clu sync adds the git plumbing and the merge rules on top.

On this page