Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ site/.cache/
/CLAUDE.md
/GEMINI.md

# `bd export -o .beads/issues.jsonl` would land an unignored file holding created_by
# account names and every bead title, description, and comment - and scan-privacy.mjs
# skips .beads, so nothing else would catch it. `relay graph` reads the export from
# stdout instead; this line covers a hand-run export.
.beads/issues.jsonl

# Beads / Dolt files (added by bd init)
.dolt/
*.db
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ Version `0.1.0` ships as a local CLI/plugin package only. A transient per-user r
- `relay run <bead-id>`
- `relay resume <bead-id>`
- `relay status [bead-id]`
- `relay graph [bead-id] [--out path.html]`
- `relay view [bead-id] [--port n] [--no-open]`
- `relay review <bead-id>`
- `relay gates <bead-id> [gate-name]`
- `relay cleanup <bead-id>`
Expand Down Expand Up @@ -100,6 +102,32 @@ They stay local and uncommitted on purpose: they name per-machine tooling and mo

Adopting a project means filling in the placeholders: the architecture documents worth reading, the real gate commands, the invariants a newcomer would violate, and who merges. The template ships the parts that are true everywhere — the inherited-`BEADS_DIR` trap, verify-don't-trust delegation, no AI attribution, and Beads as the only durable task system.

## Bead graph (`relay graph`)

`relay graph` renders the dependency graph as one self-contained HTML file: no scripts, no CDN, no network requests. It opens offline, survives in an archive, and can be published as an Artifact as-is.

The page is computed, not drawn by a model. Identical input produces identical bytes, which is what makes it reviewable and testable — the layout coordinates come from the script, so there is nothing to trust and nothing to drift.

- Nodes are laid out left to right by longest path over ordering edges, so **layer 0 is startable work**. Containment never pushes a child rightward: an epic does not block its own children.
- Solid arrows are `blocks`; dashed arrows are `parent-child`. `relates-to` is an annotation and draws nothing.
- A bead id restricts the page to that bead and its descendants; without one the whole store is drawn.
- A dependency cycle is reported in the page and the beads pinned to layer 0, rather than throwing — `bd` permits a cycle, so refusing to draw would make the store unviewable. `bd dep cycles` names them.

The data comes from `bd export --readonly` read on **stdout and never written to disk**. That is deliberate: an exported `.beads/issues.jsonl` carries `created_by` account names plus every title, description, and comment, it is not covered by `.beads/.gitignore`, and the privacy scanner skips `.beads` — two blind guards on a public repository.

Compared to `bd graph --html`, which loads D3 from `d3js.org` and so needs network access and cannot be archived, this trades interactivity for a file that always works.

## Interactive viewer (`relay view`)

`relay graph` and `relay view` are the two halves of looking at a queue:

- **`relay graph`** writes a self-contained page. Offline, diffable, attachable to a PR, and identical bytes for identical input.
- **`relay view`** opens [`@halaprix/beads-viewer`](https://www.npmjs.com/package/@halaprix/beads-viewer) on the same store, where the graph is editable — create beads, drag to connect dependencies, change status, and see a terminal `bd` land within about a second.

The viewer is an **optional external tool, never a dependency**. Agent Relay ships zero runtime dependencies and the viewer ships a browser bundle, so `relay view` fetches it through `npx` on demand. Point `AGENT_RELAY_VIEWER_BIN` at a local checkout to use one, or `AGENT_RELAY_VIEWER_PACKAGE` at a fork.

`relay view` resolves the store from the adapter and passes `BEADS_DIR` explicitly rather than letting the viewer discover it. An exported `BEADS_DIR` aimed at another project silently beats repository discovery, and editing the wrong issue database is not a mistake worth risking. A missing store is reported before the viewer launches; a viewer that cannot be run is `project-misconfigured`; a viewer exiting non-zero is `human-action-required`.

## Beads store

The Beads store is project-local. `adapter.beads.requiredDir` defaults to `.beads`, resolved against the project root, which is exactly where `bd init` creates the embedded Dolt database (`.beads/embeddeddolt/`). Nothing depends on a shared or per-user store.
Expand Down
19 changes: 18 additions & 1 deletion scripts/relay.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { parseCliArgs } from "../src/lib/cli.mjs";
import { doctor, setup, plan, run, resume, status, review, gates, cleanup } from "../src/lib/supervisor.mjs";
import { doctor, setup, plan, run, resume, status, review, gates, cleanup, graph, view } from "../src/lib/supervisor.mjs";
import { printResult, result } from "../src/lib/output.mjs";

const parsed = parseCliArgs(process.argv.slice(2));
Expand All @@ -19,6 +19,21 @@ async function main() {
return run({ projectRoot, adapterName, beadId: parsed.positionals[0] });
case "resume":
return resume({ projectRoot, adapterName, beadId: parsed.positionals[0] });
case "graph":
return graph({
projectRoot,
adapterName,
beadId: parsed.positionals[0] || null,
outPath: typeof parsed.options.out === "string" ? parsed.options.out : null
});
case "view":
return view({
projectRoot,
adapterName,
beadId: parsed.positionals[0] || null,
port: parsed.options.port ? Number(parsed.options.port) : null,
open: parsed.options["no-open"] !== true
});
case "status":
return status({ projectRoot, beadId: parsed.positionals[0] });
case "review":
Expand All @@ -39,6 +54,8 @@ async function main() {
"relay run <bead-id>",
"relay resume <bead-id>",
"relay status [bead-id]",
"relay graph [bead-id] [--out path.html]",
"relay view [bead-id] [--port n] [--no-open]",
"relay review <bead-id>",
"relay gates <bead-id> [gate-name]",
"relay cleanup <bead-id>",
Expand Down
4 changes: 3 additions & 1 deletion skills/relay/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ Use `relay` when the project wants Claude, Codex, and agy to share the same Bead
3. Plan the target Bead with `relay plan <bead-id>`.
4. Execute with `relay run <bead-id>` or reattach with `relay resume <bead-id>`.
5. Inspect checkpoints with `relay status [bead-id]`, `relay review <bead-id>`, and `relay gates <bead-id> [gate-name]`.
6. Use `relay cleanup <bead-id>` only after the human has reviewed the retained worktree state.
6. Render the queue with `relay graph [bead-id] [--out path.html]` — a self-contained page, computed by the script rather than drawn by a model, where layer 0 is startable work.
7. Open the editable view with `relay view [bead-id]` when a dependency needs fixing rather than just reading — it launches `@halaprix/beads-viewer` against the same store, as an optional external tool.
8. Use `relay cleanup <bead-id>` only after the human has reviewed the retained worktree state.

## Agent instruction files

Expand Down
221 changes: 221 additions & 0 deletions src/lib/bead-graph-html.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { STATUS_GLYPHS, STATUS_ORDER } from "./bead-graph.mjs";

// Fixed geometry: coordinates are computed here rather than by a layout library,
// so the page needs no script tag, no CDN, and no network. That is what makes the
// output diffable, archival, and safe to publish as an Artifact.
const NODE_WIDTH = 260;
const NODE_HEIGHT = 76;
const COLUMN_GAP = 96;
const ROW_GAP = 22;
const PADDING = 32;
// Lane for same-column and backward edges, kept smaller than PADDING so a routed
// edge never leaves the canvas.
const GUTTER_OFFSET = 18;
const HEADER_HEIGHT = 96;

const STATUS_COLORS = {
blocked: "#c2410c",
in_progress: "#b45309",
open: "#2563eb",
deferred: "#6b7280",
closed: "#15803d"
};

function escapeXml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

// Deterministic truncation: no measurement, just a character budget for the fixed
// node width, so the same title always renders identically.
function truncate(text, budget) {
return text.length <= budget ? text : `${text.slice(0, Math.max(0, budget - 1))}…`;
}

function layout(graph) {
const positions = new Map();
graph.layers.forEach((layer, columnIndex) => {
layer.forEach((node, rowIndex) => {
positions.set(node.id, {
x: PADDING + columnIndex * (NODE_WIDTH + COLUMN_GAP),
y: HEADER_HEIGHT + rowIndex * (NODE_HEIGHT + ROW_GAP)
});
});
});
const tallest = Math.max(1, ...graph.layers.map((layer) => layer.length));
return {
positions,
width: PADDING * 2 + Math.max(1, graph.layers.length) * (NODE_WIDTH + COLUMN_GAP) - COLUMN_GAP,
height: HEADER_HEIGHT + tallest * (NODE_HEIGHT + ROW_GAP) - ROW_GAP + PADDING
};
}

function renderEdge(edge, positions) {
const from = positions.get(edge.from);
const to = positions.get(edge.to);
if (!from || !to) {
return "";
}
const startY = from.y + NODE_HEIGHT / 2;
const endY = to.y + NODE_HEIGHT / 2;
let path;
if (to.x === from.x) {
// Same column - an epic and the children it contains. Run the edge down the
// gutter to the left of the column. Anchoring right-to-left here would cross the
// boxes, and a bezier between the two anchors would leave the canvas entirely.
const gutter = from.x - GUTTER_OFFSET;
path = `M ${from.x} ${startY} H ${gutter} V ${endY} H ${to.x}`;
} else if (to.x > from.x) {
const startX = from.x + NODE_WIDTH;
const midX = (startX + to.x) / 2;
path = `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${to.x} ${endY}`;
} else {
// Backward across columns: drop below the source row and return, staying inside
// the canvas rather than routing through negative coordinates.
const startX = from.x + NODE_WIDTH;
const laneY = Math.max(startY, endY) + NODE_HEIGHT;
path = `M ${startX} ${startY} V ${laneY} H ${to.x - GUTTER_OFFSET} V ${endY} H ${to.x}`;
}
const className = edge.kind === "containment" ? "edge containment" : "edge ordering";
return `<path class="${className}" d="${path}" marker-end="url(#arrow-${edge.kind})" />`;
}

function renderNode(node, position) {
const color = STATUS_COLORS[node.status];
const priority = node.priority ? ` · ${node.priority}` : "";
const isEpic = node.type === "epic";
return [
`<g class="node" transform="translate(${position.x} ${position.y})">`,
`<rect class="node-box${isEpic ? " epic" : ""}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="10" />`,
`<rect class="node-stripe" width="4" height="${NODE_HEIGHT}" rx="2" fill="${color}" />`,
`<text class="node-id" x="16" y="26">${escapeXml(STATUS_GLYPHS[node.status])} ${escapeXml(node.id)}${escapeXml(priority)}</text>`,
`<text class="node-title" x="16" y="48">${escapeXml(truncate(node.title, 34))}</text>`,
isEpic ? `<text class="node-kind" x="16" y="66">epic</text>` : "",
"</g>"
]
.filter(Boolean)
.join("\n");
}

function renderLegend(graph) {
const present = STATUS_ORDER.filter((status) => graph.counts[status] > 0);
return present
.map(
(status) =>
`<span class="legend-item"><span class="swatch" style="background:${STATUS_COLORS[status]}"></span>${escapeXml(STATUS_GLYPHS[status])} ${status.replace("_", " ")} ${graph.counts[status]}</span>`
)
.join("\n");
}

export function renderBeadGraphHtml(graph, { title = "Bead graph", generatedFor = null } = {}) {
const { positions, width, height } = layout(graph);
const heading = graph.rootId ? `${graph.rootId}` : "all beads";
const rootNode = graph.rootId ? graph.nodes.get(graph.rootId) : null;
const columns = graph.layers
.map(
(layer, index) =>
`<text class="column-label" x="${PADDING + index * (NODE_WIDTH + COLUMN_GAP)}" y="${HEADER_HEIGHT - 22}">layer ${index} · ${layer.length}</text>`
)
.join("\n");

return `<title>${escapeXml(title)}</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #111827;
--muted: #6b7280;
--card: #f9fafb;
--border: #d1d5db;
--edge: #9ca3af;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b0f19;
--fg: #e5e7eb;
--muted: #9ca3af;
--card: #151b28;
--border: #2b3444;
--edge: #4b5563;
}
}
:root[data-theme="dark"] {
--bg: #0b0f19;
--fg: #e5e7eb;
--muted: #9ca3af;
--card: #151b28;
--border: #2b3444;
--edge: #4b5563;
}
:root[data-theme="light"] {
--bg: #ffffff;
--fg: #111827;
--muted: #6b7280;
--card: #f9fafb;
--border: #d1d5db;
--edge: #9ca3af;
}
body {
margin: 0;
padding: 24px;
background: var(--bg);
color: var(--fg);
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
header { margin-bottom: 16px; }
h1 { margin: 0 0 4px; font-size: 20px; letter-spacing: -0.01em; }
.subtitle { color: var(--muted); margin: 0 0 12px; }
.legend { display: flex; flex-wrap: wrap; gap: 14px; color: var(--muted); font-size: 13px; }
.legend-item { display: inline-flex; align-items: center; gap: 6px; }
.swatch { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.canvas { overflow-x: auto; border: 1px solid var(--border); border-radius: 12px; background: var(--card); }
svg { display: block; }
.node-box { fill: var(--bg); stroke: var(--border); }
.node-box.epic { stroke-width: 2; stroke-dasharray: 6 3; }
.node-id { font: 600 13px ui-monospace, SFMono-Regular, Menlo, monospace; fill: var(--fg); }
.node-title { font-size: 13px; fill: var(--fg); }
.node-kind { font-size: 11px; fill: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; }
.column-label { font-size: 11px; fill: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; }
.edge { fill: none; stroke: var(--edge); stroke-width: 1.5; }
.edge.containment { stroke-dasharray: 3 4; }
.warning { margin-top: 16px; padding: 12px 14px; border-radius: 10px; border: 1px solid #c2410c; color: #c2410c; }
footer { margin-top: 16px; color: var(--muted); font-size: 12px; }
</style>
<header>
<h1>${escapeXml(heading)}${rootNode ? ` — ${escapeXml(rootNode.title)}` : ""}</h1>
<p class="subtitle">${graph.nodes.size} beads · ${graph.edges.length} edges · layer 0 is startable work</p>
<div class="legend">
${renderLegend(graph)}
<span class="legend-item">── blocks</span>
<span class="legend-item">┄┄ parent-child</span>
</div>
</header>
<div class="canvas">
<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="${escapeXml(heading)} dependency graph">
<defs>
<marker id="arrow-ordering" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--edge)" />
</marker>
<marker id="arrow-containment" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--edge)" />
</marker>
</defs>
${columns}
${graph.edges.map((edge) => renderEdge(edge, positions)).join("\n")}
${[...graph.nodes.values()]
.filter((node) => positions.has(node.id))
.map((node) => renderNode(node, positions.get(node.id)))
.join("\n")}
</svg>
</div>
${
graph.cycleEdges.length > 0
? `<div class="warning"><strong>Dependency cycle:</strong> ${escapeXml(graph.cycleEdges.join(", "))}. Those beads are pinned to layer 0 because no ordering exists. Run <code>bd dep cycles</code>.</div>`
: ""
}
<footer>Generated by <code>relay graph</code>${generatedFor ? ` for ${escapeXml(generatedFor)}` : ""}. Self-contained: no scripts, no external requests.</footer>
`;
}
Loading
Loading