SwarmSystem Template

Work Graph Template

Two things you can take right now. A starter graph you can paste into any repository, and the scorecard script that produced every number in the playbook, so you can run the same six measures on your own work.

01 The starter graph

Paste this into tasks.md

The smallest thing that is genuinely a work graph. Every node has an id, a state, a title and a blocks field. Nothing else is required, and this file works with any agent harness because it is just a file.

tasks.md
# WORK GRAPH
# state: [ ] open   [~] claimed   [x] closed
# blocks: ids that must be closed before this node can start

- [x] wg-1  Decide the hosting target          blocks:
- [x] wg-2  Register the domain                blocks:
- [~] wg-3  Write the database schema          blocks: wg-1
- [ ] wg-4  Build the API                      blocks: wg-3
- [ ] wg-5  Point staging DNS at the host      blocks: wg-2
- [ ] wg-6  Deploy to staging                  blocks: wg-4 wg-5
- [ ] wg-7  Client review                      blocks: wg-6
- [ ] wg-8  Write the launch email             blocks:
Ready work in this graph right now: wg-5 (wg-2 is closed) and wg-8 (no blockers). Not wg-4, because wg-3 is only claimed. Not wg-6, because neither blocker is closed.

That last line is the whole discipline. You did not remember which work was available. You computed it.

02 The ready query

Compute what is actually available

Drop this next to the file. It reads the graph above and prints only the nodes that are open with every blocker closed.

#!/bin/sh
# ready.sh, print unblocked open work from tasks.md
closed=$(grep -o '^- \[x\] [a-z0-9-]*' tasks.md | awk '{print $3}')
grep '^- \[ \]' tasks.md | while read -r line; do
  blockers=$(echo "$line" | sed -n 's/.*blocks:\(.*\)/\1/p')
  ready=1
  for b in $(echo "$blockers"); do
    echo "$closed" | grep -qx "$b" || ready=0
  done
  [ "$ready" = 1 ] && echo "$line"
done

One detail worth keeping: the blocker loop reads $(echo "$blockers") rather than $blockers. zsh, which is the default shell on macOS, does not word split an unquoted variable, so the plain version treats a whole blocker list as one id, finds no match, and silently reports less ready work than you have. It was wrong in our own first draft. A ready query that quietly under reports is the failure this page exists to prevent, so it is worth the four extra characters.

Tell your agent about it in whatever file it reads at startup. Two sentences is enough:

Paste into CLAUDE.md, .cursorrules, AGENTS.md, or your equivalent

The work graph is tasks.md at the repository root. Before starting any work, run ./ready.sh and pick a node from its output. Mark the node claimed with [~] while you work on it, and closed with [x] before the session ends. Never start a node that ready.sh did not return.

03 The scorecard

Measure your own graph

This is the script that produced every first party number in the playbook, including the ones that made us look bad. It reads a generic JSONL export of nodes and edges, so it works for any tracker that can dump them, not just the one we use.

# the simplest case
node measure-our-graph.cjs --issues nodes.jsonl --deps edges.jsonl

# different field names? map them, no code changes
node measure-our-graph.cjs --issues jira.jsonl --deps links.jsonl \
  --map id=key --map status=state --map created_at=createdDate \
  --map closed_at=resolutionDate --map depends_on_id=blockedBy

It expects one JSON object per line. Nodes need an id, a status, and a created timestamp. Edges need the blocked node and the blocker. Everything else is optional and the script degrades cleanly without it.

MeasureDefinitionHealthyOurs
Edge densityShare of nodes with at least one dependency edge, any type, either direction.Above 30%15.5%
Median close latencyMedian of closed minus created, across all closed nodes.Hours to days42 minutes
Create to close ratioCreated divided by closed, worst calendar month.Under 1.5 : 13.7 : 1
Backlog median ageMedian age in days of open nodes at the export date.Under 30 days26d
Claim rateShare of nodes that ever got a claim, assign or start event.Near 100% with agents12%
Open top priorityOpen nodes at the graph's highest priority value.Single digits39

Our column is measured from a real 1,328 node graph on 2026-08-31. If your numbers are better than ours, they probably are, and that is the useful outcome.

measure-our-graph.cjs · 143 lines · no dependencies
#!/usr/bin/env node
/**
 * measure-our-graph.cjs, compute six work-graph health measures.
 *
 * SUBSTRATE-AGNOSTIC BY DESIGN. It reads a generic JSONL export of nodes and edges.
 * Any tracker that can emit one object per line with an id, a status, a created
 * timestamp and optionally a closed timestamp works here. Beads is what we happen
 * to run; nothing below requires it.
 *
 *   node measure-our-graph.cjs --issues <path> --deps <path> [--events <path>]
 *                              [--asof YYYY-MM-DD] [--out <path>]
 *
 * Remap field names if your export differs:
 *   --map id=key --map created_at=createdDate --map depends_on_id=blockedBy
 */
const fs = require('fs');

const argv = process.argv.slice(2);
const arg = (n, d) => { const i = argv.indexOf('--' + n); return i > -1 ? argv[i + 1] : d; };
const maps = {};
argv.forEach((a, i) => { if (a === '--map') { const [k, v] = argv[i + 1].split('='); maps[k] = v; } });
const F = n => maps[n] || n;

const readJSONL = p => fs.readFileSync(p, 'utf8').split('\n')
  .filter(l => l.trim()).map((l, i) => { try { return JSON.parse(l); }
    catch (e) { throw new Error(p + ': line ' + (i + 1) + ' is not valid JSON'); } });

const ISSUES = arg('issues', '.beads/backup/issues.jsonl');
const DEPS   = arg('deps',   '.beads/backup/dependencies.jsonl');
const EVENTS = arg('events', '.beads/backup/events.jsonl');
const OUT    = arg('out',    'our-graph-stats.json');

for (const p of [ISSUES, DEPS]) if (!fs.existsSync(p)) {
  console.error('FATAL: required input missing: ' + p); process.exit(1); }

const issues = readJSONL(ISSUES);
const deps   = readJSONL(DEPS);
const events = fs.existsSync(EVENTS) ? readJSONL(EVENTS) : [];

const D = s => { const d = new Date(s); return isNaN(d) ? null : d; };
const created = issues.map(i => D(i[F('created_at')])).filter(Boolean).sort((a, b) => a - b);
if (!created.length) { console.error('FATAL: no parseable created timestamps'); process.exit(1); }

const ASOF = arg('asof') ? new Date(arg('asof') + 'T00:00:00Z') : created[created.length - 1];
const DAY = 86400000;
const med = a => { if (!a.length) return null; const s = [...a].sort((x, y) => x - y);
  const m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
const pct = (n, d) => d ? +(100 * n / d).toFixed(1) : null;
const ym = d => d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0');

// measure 1: edge density
const touched = new Set();
deps.forEach(d => { touched.add(d[F('issue_id')]); touched.add(d[F('depends_on_id')]); });
const ids = new Set(issues.map(i => i[F('id')]));
const withEdge = [...touched].filter(x => ids.has(x)).length;

// measure 2: median close latency
const closedIssues = issues.filter(i => i[F('closed_at')] && i[F('created_at')]);
const latencies = closedIssues.map(i => (D(i[F('closed_at')]) - D(i[F('created_at')])) / 60000)
  .filter(n => Number.isFinite(n) && n >= 0);
const medLatency = med(latencies);
const p90Latency = latencies.length
  ? [...latencies].sort((a, b) => a - b)[Math.floor(latencies.length * 0.9)] : null;

// measure 3: create-to-close ratio, worst month
const byMonth = {};
issues.forEach(i => { const c = D(i[F('created_at')]); if (c) { (byMonth[ym(c)] = byMonth[ym(c)] || { c: 0, x: 0 }).c++; } });
issues.forEach(i => { const c = D(i[F('closed_at')]);  if (c) { (byMonth[ym(c)] = byMonth[ym(c)] || { c: 0, x: 0 }).x++; } });
const months = Object.entries(byMonth).sort();
const ratios = months.filter(([, v]) => v.x > 0 && v.c > 0)
  .map(([m, v]) => ({ month: m, ratio: v.c / v.x, c: v.c, x: v.x }));
const worst = ratios.length ? ratios.reduce((a, b) => (b.ratio > a.ratio ? b : a)) : null;

// measure 4: open backlog age
const STATUS = F('status');
const open = issues.filter(i => i[STATUS] === 'open' && i[F('created_at')]);
const ages = open.map(i => (ASOF - D(i[F('created_at')])) / DAY).filter(n => Number.isFinite(n));
const sortedAges = [...ages].sort((a, b) => a - b);

// measure 5: claim rate
const claimKinds = new Set(['claimed', 'assigned', 'started', 'in_progress']);
const claimed = new Set(events.filter(e => claimKinds.has(e.event_kind || e.event_type))
  .map(e => e.issue_id || e.id).filter(Boolean));

// measure 6: open top-priority
const prios = issues.map(i => Number(i[F('priority')])).filter(Number.isFinite);
const topPrio = prios.length ? Math.min(...prios) : null;
const openTop = open.filter(i => Number(i[F('priority')]) === topPrio).length;

// graph shape
const blocks = deps.filter(d => (d[F('type')] || 'blocks') === 'blocks');
const blockerCount = {};
blocks.forEach(d => { blockerCount[d[F('issue_id')]] = (blockerCount[d[F('issue_id')]] || 0) + 1; });
const deepest = Object.entries(blockerCount).sort((a, b) => b[1] - a[1])[0] || [null, 0];
const edgeTypes = {}; deps.forEach(d => { const t = d[F('type')] || 'blocks'; edgeTypes[t] = (edgeTypes[t] || 0) + 1; });
const statuses = {};  issues.forEach(i => { const s = i[STATUS] || 'unknown'; statuses[s] = (statuses[s] || 0) + 1; });

const m2h = v => v === null ? null : (v < 90 ? Math.round(v) + ' minutes' : (v / 1440).toFixed(1) + ' days');

const stats = {
  generated_at: new Date().toISOString(),
  as_of: ASOF.toISOString().slice(0, 10),
  inputs: { issues: ISSUES, deps: DEPS, events: EVENTS },
  window: { first_created: created[0].toISOString().slice(0, 10),
            last_created: created[created.length - 1].toISOString().slice(0, 10),
            span_days: Math.round((created[created.length - 1] - created[0]) / DAY) },
  totals: { issues: issues.length, edges: deps.length, events: events.length, statuses, edge_types: edgeTypes },
  measures: {
    edge_density:       { nodes_with_edge: withEdge, total: issues.length, pct: pct(withEdge, issues.length),
                          orphans: issues.length - withEdge },
    close_latency:      { closed: closedIssues.length,
                          median_minutes: medLatency === null ? null : Math.round(medLatency),
                          median_human: m2h(medLatency),
                          p90_days: p90Latency === null ? null : +(p90Latency / 1440).toFixed(1) },
    create_close_ratio: { worst_month: worst && worst.month, created: worst && worst.c, closed: worst && worst.x,
                          ratio: worst ? +worst.ratio.toFixed(1) : null,
                          by_month: months.map(([mm, v]) => ({ month: mm, created: v.c, closed: v.x })) },
    backlog_age:        { open: open.length,
                          median_days: sortedAges.length ? Math.round(med(sortedAges)) : null,
                          p90_days: sortedAges.length ? Math.round(sortedAges[Math.floor(sortedAges.length * 0.9)]) : null,
                          max_days: sortedAges.length ? Math.round(sortedAges[sortedAges.length - 1]) : null,
                          over_30: ages.filter(a => a > 30).length,
                          over_60: ages.filter(a => a > 60).length,
                          over_90: ages.filter(a => a > 90).length },
    claim_rate:         { claimed: claimed.size, total: issues.length, pct: pct(claimed.size, issues.length) },
    open_top_priority:  { priority: topPrio, count: openTop }
  },
  shape: { deepest_node: deepest[0], deepest_blockers: deepest[1] }
};

fs.writeFileSync(OUT, JSON.stringify(stats, null, 2));
const m = stats.measures;
console.log('wrote ' + OUT);
console.log('  window        ' + stats.window.first_created + ' to ' + stats.window.last_created + ' (' + stats.window.span_days + ' days)');
console.log('  totals        ' + stats.totals.issues + ' nodes, ' + stats.totals.edges + ' edges, ' + stats.totals.events + ' events');
console.log('  1 edge density   ' + m.edge_density.pct + '%  (' + m.edge_density.nodes_with_edge + '/' + m.edge_density.total + ', ' + m.edge_density.orphans + ' orphans)');
console.log('  2 close latency  median ' + m.close_latency.median_human + ', p90 ' + m.close_latency.p90_days + 'd');
console.log('  3 create:close   ' + m.create_close_ratio.ratio + ':1 worst ' + m.create_close_ratio.worst_month + ' (' + m.create_close_ratio.created + '/' + m.create_close_ratio.closed + ')');
console.log('  4 backlog age    ' + m.backlog_age.open + ' open, median ' + m.backlog_age.median_days + 'd, p90 ' + m.backlog_age.p90_days + 'd, >90d ' + m.backlog_age.over_90);
console.log('  5 claim rate     ' + m.claim_rate.pct + '%  (' + m.claim_rate.claimed + '/' + m.claim_rate.total + ')');
console.log('  6 open P' + m.open_top_priority.priority + '       ' + m.open_top_priority.count);
console.log('  deepest node     ' + stats.shape.deepest_node + ' with ' + stats.shape.deepest_blockers + ' blockers');
Node 18 or later. No packages to install. Writes a JSON file you can diff against last month's.
04 Next

Where to go from here

SELF AUDIT: 0 WORDS · 0 CODE BLOCKS · 0 TOKENS · 0 EM DASHES · 0 CONSOLE ERRORS · PASSED
Book a call