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.
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.
# 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:
That last line is the whole discipline. You did not remember which work was available. You computed it.
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:
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.
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.
| Measure | Definition | Healthy | Ours |
|---|---|---|---|
| Edge density | Share of nodes with at least one dependency edge, any type, either direction. | Above 30% | 15.5% |
| Median close latency | Median of closed minus created, across all closed nodes. | Hours to days | 42 minutes |
| Create to close ratio | Created divided by closed, worst calendar month. | Under 1.5 : 1 | 3.7 : 1 |
| Backlog median age | Median age in days of open nodes at the export date. | Under 30 days | 26d |
| Claim rate | Share of nodes that ever got a claim, assign or start event. | Near 100% with agents | 12% |
| Open top priority | Open nodes at the graph's highest priority value. | Single digits | 39 |
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.
#!/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');