diff --git a/docs/src/pages/cli.astro b/docs/src/pages/cli.astro index 93683bd..9667cd7 100644 --- a/docs/src/pages/cli.astro +++ b/docs/src/pages/cli.astro @@ -243,15 +243,17 @@ dex sync --dry-run # Preview sync`}
Import a GitHub Issue or Shortcut Story as a dex task.
+Import a GitHub Issue, Shortcut Story, or Beads JSONL export as dex tasks.
--all — Import all items with the dex label--beads <path> — Import from Beads JSONL export file--github — Filter --all to only GitHub--shortcut — Filter --all to only Shortcut--update — Update existing task if already imported--dry-run — Preview what would be importedBeads selection: pass one or more issue IDs after --beads <path> to import only those issues and their descendants.
Reference formats:
#123, owner/repo#123, or full URL [issue-id...]` to ingest Beads JSONL exports into Dex tasks.
+
+## Goals
+
+- Import Beads issue graphs without adding a full sync integration.
+- Preserve Beads provenance in task metadata.
+- Keep imports idempotent and safe to re-run.
+- Keep existing GitHub/Shortcut import flows unchanged.
+
+## CLI Contract
+
+- New flag: `--beads `
+- Supported with:
+ - `--dry-run`
+ - `--update`
+- Optional positional arguments in Beads mode:
+ - `[issue-id...]` to import one or more root Beads issues and all descendants
+- Invalid combinations:
+ - `--beads` with `--all`, `--github`, or `--shortcut`
+
+## Data Mapping
+
+- `id` -> task `id`
+- `title` -> task `name`
+- `description` -> task `description`
+- `priority` -> task `priority`
+- `status=closed` (or `closed_at` present) -> `completed=true`
+- `created_at`, `updated_at`, `closed_at` -> task timestamps
+- `status in {in_progress, hooked}` -> `started_at` (best-effort from `updated_at`)
+- Dependency type `parent-child` -> task `parent_id`
+- Dependency type `blocks` -> task `blockedBy`
+- Non-blocking dependency types are preserved in `metadata.beads` and not mapped to Dex relationships.
+
+## Implementation Shape
+
+- Add Beads parser/normalizer under `src/core/beads/`.
+- Extend task metadata schema with `metadata.beads` in `src/types.ts`.
+- Extend `src/cli/import.ts` to handle `--beads` branch.
+- Apply import in two passes:
+ 1. Upsert task fields (create/update)
+ 2. Apply relationships (parent + blockers)
+
+Relationship failures (depth/cycle/missing target) should produce warnings and continue.
+
+## Test Strategy
+
+- Parser tests in `src/core/beads/import.test.ts`:
+ - valid JSONL parsing
+ - dependency normalization
+ - malformed line handling (line number in error)
+- CLI tests in `src/cli/import.test.ts`:
+ - happy path import
+ - dry-run no writes
+ - update semantics
+ - invalid flag combinations
+ - relationship warnings do not abort import
+- Schema test in `src/types.test.ts` for `metadata.beads` compatibility.
+
+## Anonymized Fixtures
+
+- Add anonymized Beads-derived fixtures under `src/core/beads/fixtures/`.
+- Produce fixture data from local Beads exports via an external/local workflow that:
+ - pseudonymizes IDs/actors/labels/external refs
+ - redacts free-text fields
+ - preserves graph shape, status mix, priorities, and dependency semantics
+
+No raw Beads state or secret-bearing exports are committed.
diff --git a/src/cli/help.ts b/src/cli/help.ts
index 28b446d..188e192 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -50,6 +50,7 @@ ${colors.bold}COMMANDS:${colors.reset}
import #N Import GitHub issue
import sc#N Import Shortcut story
import --all Import all dex-labeled items
+ import --beads Import tasks from Beads JSONL export
export ... Export tasks to GitHub (no sync back)
completion Generate shell completion script
@@ -102,9 +103,11 @@ ${colors.bold}EXAMPLES:${colors.reset}
dex sync --dry-run # Preview what would be synced
${colors.dim}# Import from external services:${colors.reset}
- dex import #42 # Import GitHub issue #42
- dex import sc#123 # Import Shortcut story #123
- dex import --all # Import all dex-labeled items
- dex import --all --shortcut # Import only from Shortcut
+ dex import #42 # Import GitHub issue #42
+ dex import sc#123 # Import Shortcut story #123
+ dex import --all # Import all dex-labeled items
+ dex import --all --shortcut # Import only from Shortcut
+ dex import --beads ./beads.jsonl # Import all from Beads export
+ dex import --beads ./beads.jsonl id1 id2 # Import selected Beads trees
`);
}
diff --git a/src/cli/import.test.ts b/src/cli/import.test.ts
index 823f32c..3bf29ca 100644
--- a/src/cli/import.test.ts
+++ b/src/cli/import.test.ts
@@ -1,3 +1,5 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { FileStorage } from "../core/storage/index.js";
import { runCli } from "./index.js";
@@ -1267,4 +1269,371 @@ Old subtask context.
expect(task.priority).toBe(5);
});
});
+
+ describe("Beads import", () => {
+ function writeBeadsFixture(contents: string): string {
+ const fixturePath = path.join(
+ storage.getIdentifier(),
+ "beads-fixture.jsonl",
+ );
+ fs.writeFileSync(fixturePath, contents, "utf-8");
+ return fixturePath;
+ }
+
+ it("imports tasks from --beads", async () => {
+ const filePath = writeBeadsFixture(
+ [
+ JSON.stringify({
+ id: "beads-parent",
+ title: "Parent task",
+ description: "Parent description",
+ status: "open",
+ priority: 1,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:10:00Z",
+ }),
+ JSON.stringify({
+ id: "beads-child",
+ title: "Child task",
+ description: "Child description",
+ status: "hooked",
+ priority: 2,
+ created_at: "2026-01-01T00:20:00Z",
+ updated_at: "2026-01-01T00:30:00Z",
+ dependencies: [
+ {
+ issue_id: "beads-child",
+ depends_on_id: "beads-parent",
+ type: "parent-child",
+ },
+ {
+ issue_id: "beads-child",
+ depends_on_id: "beads-parent",
+ type: "blocks",
+ },
+ ],
+ }),
+ ].join("\n") + "\n",
+ );
+
+ await runCli(["import", "--beads", filePath], { storage });
+
+ const out = output.stdout.join("\n");
+ expect(out).toContain("Beads: Imported 2, updated 0 task(s)");
+
+ const tasks = await storage.readAsync();
+ expect(tasks.tasks).toHaveLength(2);
+
+ const parent = tasks.tasks.find((task) => task.id === "beads-parent");
+ const child = tasks.tasks.find((task) => task.id === "beads-child");
+
+ expect(parent).toBeDefined();
+ expect(parent?.metadata?.beads?.issueId).toBe("beads-parent");
+ expect(child).toBeDefined();
+ expect(child?.parent_id).toBe("beads-parent");
+ expect(child?.blockedBy).toEqual(["beads-parent"]);
+ expect(child?.started_at).toBe("2026-01-01T00:30:00Z");
+ });
+
+ it("supports --dry-run for --beads", async () => {
+ const filePath = writeBeadsFixture(
+ `${JSON.stringify({ id: "dry-run-1", title: "Dry run", status: "open", priority: 1 })}\n`,
+ );
+
+ await runCli(["import", "--beads", filePath, "--dry-run"], {
+ storage,
+ });
+
+ const out = output.stdout.join("\n");
+ expect(out).toContain("Would import 1 and update 0 task(s)");
+
+ const tasks = await storage.readAsync();
+ expect(tasks.tasks).toHaveLength(0);
+ });
+
+ it("updates existing tasks from Beads with --update", async () => {
+ const filePath = writeBeadsFixture(
+ [
+ JSON.stringify({
+ id: "update-parent",
+ title: "Parent v1",
+ status: "open",
+ priority: 1,
+ }),
+ JSON.stringify({
+ id: "update-child",
+ title: "Child v1",
+ status: "open",
+ priority: 2,
+ dependencies: [
+ {
+ issue_id: "update-child",
+ depends_on_id: "update-parent",
+ type: "blocks",
+ },
+ ],
+ }),
+ ].join("\n") + "\n",
+ );
+
+ await runCli(["import", "--beads", filePath], { storage });
+
+ writeBeadsFixture(
+ [
+ JSON.stringify({
+ id: "update-parent",
+ title: "Parent v2",
+ status: "closed",
+ priority: 1,
+ closed_at: "2026-01-02T00:00:00Z",
+ close_reason: "Done",
+ }),
+ JSON.stringify({
+ id: "update-child",
+ title: "Child v2",
+ status: "open",
+ priority: 3,
+ dependencies: [],
+ }),
+ ].join("\n") + "\n",
+ );
+
+ await runCli(["import", "--beads", filePath, "--update"], {
+ storage,
+ });
+
+ const tasks = await storage.readAsync();
+ const parent = tasks.tasks.find((task) => task.id === "update-parent");
+ const child = tasks.tasks.find((task) => task.id === "update-child");
+
+ expect(parent?.name).toBe("Parent v2");
+ expect(parent?.completed).toBe(true);
+ expect(new Date(parent?.completed_at ?? "").toISOString()).toBe(
+ "2026-01-02T00:00:00.000Z",
+ );
+ expect(child?.name).toBe("Child v2");
+ expect(child?.priority).toBe(3);
+ expect(child?.blockedBy).toEqual([]);
+ });
+
+ it("preserves completed_at on update when Beads closed issue has no closed_at", async () => {
+ const filePath = writeBeadsFixture(
+ `${JSON.stringify({
+ id: "completed-preserve",
+ title: "Completed v1",
+ status: "closed",
+ priority: 1,
+ closed_at: "2026-01-05T00:00:00Z",
+ })}\n`,
+ );
+
+ await runCli(["import", "--beads", filePath], { storage });
+
+ writeBeadsFixture(
+ `${JSON.stringify({
+ id: "completed-preserve",
+ title: "Completed v2",
+ status: "closed",
+ priority: 1,
+ })}\n`,
+ );
+
+ await runCli(["import", "--beads", filePath, "--update"], {
+ storage,
+ });
+
+ const tasks = await storage.readAsync();
+ const task = tasks.tasks.find((item) => item.id === "completed-preserve");
+
+ expect(task?.completed).toBe(true);
+ expect(task?.name).toBe("Completed v2");
+ expect(new Date(task?.completed_at ?? "").toISOString()).toBe(
+ "2026-01-05T00:00:00.000Z",
+ );
+ });
+
+ it("reports warnings for missing relationship targets without failing import", async () => {
+ const filePath = writeBeadsFixture(
+ `${JSON.stringify({
+ id: "warn-1",
+ title: "Warn issue",
+ status: "open",
+ priority: 1,
+ dependencies: [
+ {
+ issue_id: "warn-1",
+ depends_on_id: "missing-issue",
+ type: "blocks",
+ },
+ ],
+ })}\n`,
+ );
+
+ await runCli(["import", "--beads", filePath], { storage });
+
+ const out = output.stdout.join("\n");
+ expect(out).toContain("Warnings:");
+ expect(out).toContain("missing-issue");
+
+ const tasks = await storage.readAsync();
+ expect(tasks.tasks).toHaveLength(1);
+ expect(tasks.tasks[0].id).toBe("warn-1");
+ });
+
+ it("imports selected Beads issue and all descendants", async () => {
+ const filePath = writeBeadsFixture(
+ [
+ JSON.stringify({
+ id: "tree-root",
+ title: "Root",
+ status: "open",
+ priority: 1,
+ }),
+ JSON.stringify({
+ id: "tree-child",
+ title: "Child",
+ status: "open",
+ priority: 1,
+ dependencies: [
+ {
+ issue_id: "tree-child",
+ depends_on_id: "tree-root",
+ type: "parent-child",
+ },
+ ],
+ }),
+ JSON.stringify({
+ id: "tree-grandchild",
+ title: "Grandchild",
+ status: "open",
+ priority: 1,
+ dependencies: [
+ {
+ issue_id: "tree-grandchild",
+ depends_on_id: "tree-child",
+ type: "parent-child",
+ },
+ ],
+ }),
+ JSON.stringify({
+ id: "other-root",
+ title: "Other root",
+ status: "open",
+ priority: 1,
+ }),
+ ].join("\n") + "\n",
+ );
+
+ await runCli(["import", "--beads", filePath, "tree-root"], { storage });
+
+ const tasks = await storage.readAsync();
+ const importedIds = tasks.tasks.map((task) => task.id).sort();
+ expect(importedIds).toEqual([
+ "tree-child",
+ "tree-grandchild",
+ "tree-root",
+ ]);
+
+ const child = tasks.tasks.find((task) => task.id === "tree-child");
+ const grandchild = tasks.tasks.find(
+ (task) => task.id === "tree-grandchild",
+ );
+ expect(child?.parent_id).toBe("tree-root");
+ expect(grandchild?.parent_id).toBe("tree-child");
+ });
+
+ it("imports multiple selected Beads issue trees", async () => {
+ const filePath = writeBeadsFixture(
+ [
+ JSON.stringify({
+ id: "alpha",
+ title: "Alpha",
+ status: "open",
+ priority: 1,
+ }),
+ JSON.stringify({
+ id: "alpha-child",
+ title: "Alpha child",
+ status: "open",
+ priority: 1,
+ dependencies: [
+ {
+ issue_id: "alpha-child",
+ depends_on_id: "alpha",
+ type: "parent-child",
+ },
+ ],
+ }),
+ JSON.stringify({
+ id: "beta",
+ title: "Beta",
+ status: "open",
+ priority: 1,
+ }),
+ JSON.stringify({
+ id: "beta-child",
+ title: "Beta child",
+ status: "open",
+ priority: 1,
+ dependencies: [
+ {
+ issue_id: "beta-child",
+ depends_on_id: "beta",
+ type: "parent-child",
+ },
+ ],
+ }),
+ JSON.stringify({
+ id: "gamma",
+ title: "Gamma",
+ status: "open",
+ priority: 1,
+ }),
+ ].join("\n") + "\n",
+ );
+
+ await runCli(["import", "--beads", filePath, "alpha", "beta"], {
+ storage,
+ });
+
+ const tasks = await storage.readAsync();
+ const importedIds = tasks.tasks.map((task) => task.id).sort();
+ expect(importedIds).toEqual([
+ "alpha",
+ "alpha-child",
+ "beta",
+ "beta-child",
+ ]);
+ });
+
+ it("fails when selected Beads issue ids are missing", async () => {
+ const filePath = writeBeadsFixture(
+ `${JSON.stringify({ id: "known-1", title: "Known", status: "open", priority: 1 })}\n`,
+ );
+
+ await expect(
+ runCli(["import", "--beads", filePath, "missing-1", "missing-2"], {
+ storage,
+ }),
+ ).rejects.toThrow("process.exit");
+
+ const err = output.stderr.join("\n");
+ expect(err).toContain(
+ "Beads issue id(s) not found in export: missing-1, missing-2",
+ );
+ });
+
+ it("rejects invalid flag combinations with --beads", async () => {
+ const filePath = writeBeadsFixture(
+ `${JSON.stringify({ id: "invalid-1", title: "Invalid", status: "open" })}\n`,
+ );
+
+ await expect(
+ runCli(["import", "--beads", filePath, "--all"], { storage }),
+ ).rejects.toThrow("process.exit");
+
+ const err = output.stderr.join("\n");
+ expect(err).toContain("cannot be combined");
+ });
+ });
});
diff --git a/src/cli/import.ts b/src/cli/import.ts
index a156001..fbb13f7 100644
--- a/src/cli/import.ts
+++ b/src/cli/import.ts
@@ -1,7 +1,9 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
import type { CliOptions } from "./utils.js";
import { createService, formatCliError } from "./utils.js";
import { colors } from "./colors.js";
-import { getBooleanFlag, parseArgs } from "./args.js";
+import { getBooleanFlag, getStringFlag, parseArgs } from "./args.js";
import type { GitHubRepo } from "../core/github/index.js";
import {
getGitHubIssueNumber,
@@ -18,6 +20,10 @@ import {
parseTaskMetadata as parseShortcutTaskMetadata,
parseStoryDescription,
} from "../core/shortcut/index.js";
+import {
+ parseBeadsExportJsonl,
+ type ParsedBeadsIssue,
+} from "../core/beads/index.js";
import { loadConfig } from "../core/config.js";
import type { Task, ShortcutMetadata } from "../types.js";
import { Octokit } from "@octokit/rest";
@@ -32,6 +38,7 @@ export async function importCommand(
all: { hasValue: false },
"dry-run": { hasValue: false },
update: { hasValue: false },
+ beads: { hasValue: true },
github: { hasValue: false },
shortcut: { hasValue: false },
help: { short: "h", hasValue: false },
@@ -40,30 +47,35 @@ export async function importCommand(
);
if (getBooleanFlag(flags, "help")) {
- console.log(`${colors.bold}dex import${colors.reset} - Import GitHub Issues or Shortcut Stories as tasks
+ console.log(`${colors.bold}dex import${colors.reset} - Import GitHub, Shortcut, or Beads items as tasks
${colors.bold}USAGE:${colors.reset}
- dex import #123 # Import GitHub issue #123
- dex import sc#123 # Import Shortcut story #123
- dex import # Import by full URL
- dex import --all # Import all dex-labeled items
- dex import --all --github # Import only from GitHub
- dex import --all --shortcut # Import only from Shortcut
- dex import --dry-run # Preview without importing
- dex import #123 --update # Update existing task
+ dex import #123 # Import GitHub issue #123
+ dex import sc#123 # Import Shortcut story #123
+ dex import --beads data.jsonl # Import all issues from Beads export
+ dex import --beads data.jsonl id1 id2 # Import selected Beads issues + descendants
+ dex import # Import by full URL
+ dex import --all # Import all dex-labeled items
+ dex import --all --github # Import only from GitHub
+ dex import --all --shortcut # Import only from Shortcut
+ dex import --dry-run # Preview without importing
+ dex import #123 --update # Update existing task
${colors.bold}ARGUMENTS:${colors.reset}
- Reference format:
- GitHub: #N, URL, or owner/repo#N
- Shortcut: sc#N, SC#N, or full URL
+ Reference format (ref mode only):
+ GitHub: #N, URL, or owner/repo#N
+ Shortcut: sc#N, SC#N, or full URL
+ [issue-id...] Optional Beads issue IDs (beads mode only)
+ Imports each selected issue and all descendants
${colors.bold}OPTIONS:${colors.reset}
- --all Import all items with dex label
- --github Filter --all to only GitHub
- --shortcut Filter --all to only Shortcut
- --update Update existing task if already imported
- --dry-run Show what would be imported without making changes
- -h, --help Show this help message
+ --all Import all items with dex label
+ --beads Import from Beads JSONL export file
+ --github Filter --all to only GitHub
+ --shortcut Filter --all to only Shortcut
+ --update Update existing task if already imported
+ --dry-run Show what would be imported without making changes
+ -h, --help Show this help message
${colors.bold}REQUIREMENTS:${colors.reset}
GitHub:
@@ -73,9 +85,14 @@ ${colors.bold}REQUIREMENTS:${colors.reset}
Shortcut:
- SHORTCUT_API_TOKEN environment variable
+ Beads:
+ - Local JSONL export file (for example from 'bd export')
+
${colors.bold}EXAMPLE:${colors.reset}
dex import #42 # Import GitHub issue
dex import sc#123 # Import Shortcut story
+ dex import --beads ~/tmp/beads.jsonl # Import all from Beads
+ dex import --beads ~/tmp/beads.jsonl i1 i2 # Import selected Beads issues + descendants
dex import https://github.com/user/repo/issues/42
dex import https://app.shortcut.com/myorg/story/123
dex import --all # Import all dex items
@@ -87,17 +104,31 @@ ${colors.bold}EXAMPLE:${colors.reset}
const ref = positional[0];
const importAll = getBooleanFlag(flags, "all");
+ const beadsFile = getStringFlag(flags, "beads");
+ const beadsIssueIds = beadsFile ? positional : [];
const dryRun = getBooleanFlag(flags, "dry-run");
const update = getBooleanFlag(flags, "update");
const githubOnly = getBooleanFlag(flags, "github");
const shortcutOnly = getBooleanFlag(flags, "shortcut");
- if (!ref && !importAll) {
+ if (beadsFile) {
+ if (importAll || githubOnly || shortcutOnly) {
+ console.error(
+ `${colors.red}Error:${colors.reset} --beads cannot be combined with --all, --github, or --shortcut`,
+ );
+ console.error(
+ `Usage: dex import --beads [issue-id...] [--update] [--dry-run]`,
+ );
+ process.exit(1);
+ }
+ }
+
+ if (!ref && !importAll && !beadsFile) {
console.error(
`${colors.red}Error:${colors.reset} Reference or --all required`,
);
console.error(
- `Usage: dex import #123, dex import sc#123, or dex import --all`,
+ `Usage: dex import #123, dex import sc#123, dex import --all, or dex import --beads [issue-id...]`,
);
process.exit(1);
}
@@ -106,7 +137,15 @@ ${colors.bold}EXAMPLE:${colors.reset}
const service = createService(options);
try {
- if (importAll) {
+ if (beadsFile) {
+ await importFromBeadsFile(
+ service,
+ beadsFile,
+ dryRun,
+ update,
+ beadsIssueIds,
+ );
+ } else if (importAll) {
// Import all from GitHub and/or Shortcut
const importFromGitHub = !shortcutOnly;
const importFromShortcut = !githubOnly;
@@ -159,6 +198,283 @@ function parseShortcutRef(
return null;
}
+// ============================================================
+// Beads Import Functions
+// ============================================================
+
+async function importFromBeadsFile(
+ service: ReturnType,
+ filePath: string,
+ dryRun: boolean,
+ update: boolean,
+ requestedIssueIds: string[] = [],
+): Promise {
+ const resolvedPath = path.resolve(filePath);
+
+ let input: string;
+ try {
+ input = fs.readFileSync(resolvedPath, "utf-8");
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ throw new Error(`Failed to read Beads file ${resolvedPath}: ${message}`);
+ }
+
+ const parsed = parseBeadsExportJsonl(input);
+ const parseWarnings = [...parsed.warnings];
+
+ if (parsed.issues.length === 0) {
+ console.log(`No Beads issues found in ${resolvedPath}.`);
+ if (parseWarnings.length > 0) {
+ printBeadsWarnings(parseWarnings);
+ }
+ return;
+ }
+
+ const issuesToImport = selectBeadsIssues(parsed.issues, requestedIssueIds);
+
+ const existingTasks = await service.list({ all: true });
+ const existingById = new Map(existingTasks.map((task) => [task.id, task]));
+
+ const toCreate = issuesToImport.filter(
+ (issue) => !existingById.has(issue.id),
+ );
+ const toExisting = issuesToImport.filter((issue) =>
+ existingById.has(issue.id),
+ );
+
+ if (dryRun) {
+ const wouldUpdate = update ? toExisting.length : 0;
+ const wouldSkip = update ? 0 : toExisting.length;
+
+ console.log(
+ `Would import ${toCreate.length} and update ${wouldUpdate} task(s) from Beads file ${colors.cyan}${resolvedPath}${colors.reset}`,
+ );
+ if (wouldSkip > 0) {
+ console.log(
+ `Would skip ${wouldSkip} existing task(s) (use --update to refresh)`,
+ );
+ }
+
+ if (parseWarnings.length > 0) {
+ printBeadsWarnings(parseWarnings);
+ }
+ return;
+ }
+
+ let created = 0;
+ let updated = 0;
+ let skipped = 0;
+ const createdIds = new Set();
+
+ for (const issue of issuesToImport) {
+ const existing = existingById.get(issue.id);
+
+ if (existing) {
+ if (!update) {
+ skipped++;
+ continue;
+ }
+
+ await service.update({
+ id: issue.id,
+ name: issue.name,
+ description: issue.description,
+ priority: issue.priority,
+ completed: issue.completed,
+ ...(!issue.completed
+ ? { completed_at: null }
+ : issue.completed_at
+ ? { completed_at: issue.completed_at }
+ : {}),
+ result: issue.completed ? issue.result : null,
+ started_at: issue.started_at ?? null,
+ metadata: {
+ ...(existing.metadata ?? {}),
+ beads: issue.beadsMetadata,
+ },
+ });
+ updated++;
+ continue;
+ }
+
+ await service.create({
+ id: issue.id,
+ name: issue.name,
+ description: issue.description,
+ priority: issue.priority,
+ completed: issue.completed,
+ result: issue.result,
+ created_at: issue.created_at,
+ updated_at: issue.updated_at,
+ started_at: issue.started_at,
+ completed_at: issue.completed_at,
+ metadata: {
+ beads: issue.beadsMetadata,
+ },
+ });
+ createdIds.add(issue.id);
+ created++;
+ }
+
+ const relationshipWarnings = [...parseWarnings];
+ const currentTasks = await service.list({ all: true });
+ const currentById = new Map(currentTasks.map((task) => [task.id, task]));
+
+ for (const issue of issuesToImport) {
+ const shouldApplyRelationships =
+ createdIds.has(issue.id) || (update && existingById.has(issue.id));
+ if (!shouldApplyRelationships) continue;
+
+ const current = currentById.get(issue.id);
+ if (!current) {
+ relationshipWarnings.push(
+ `Issue ${issue.id}: task was not found after import; skipping relationship sync`,
+ );
+ continue;
+ }
+
+ const desiredParent = issue.parentId ?? null;
+ if (desiredParent !== current.parent_id) {
+ if (desiredParent && !currentById.has(desiredParent)) {
+ relationshipWarnings.push(
+ `Issue ${issue.id}: parent ${desiredParent} is missing, skipping parent link`,
+ );
+ } else {
+ try {
+ await service.update({ id: issue.id, parent_id: desiredParent });
+ current.parent_id = desiredParent;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ relationshipWarnings.push(
+ `Issue ${issue.id}: could not set parent to ${desiredParent ?? "(none)"}: ${message}`,
+ );
+ }
+ }
+ }
+
+ const desiredBlockers: string[] = [];
+ for (const blockerId of issue.blockerIds) {
+ if (blockerId === issue.id) {
+ relationshipWarnings.push(
+ `Issue ${issue.id}: self-blocking dependency ignored`,
+ );
+ continue;
+ }
+ if (!currentById.has(blockerId)) {
+ relationshipWarnings.push(
+ `Issue ${issue.id}: blocker ${blockerId} missing, skipping blocker link`,
+ );
+ continue;
+ }
+ desiredBlockers.push(blockerId);
+ }
+
+ const currentBlockers = new Set(current.blockedBy);
+ const desiredSet = new Set(desiredBlockers);
+
+ const addBlockedBy = [...desiredSet].filter(
+ (id) => !currentBlockers.has(id),
+ );
+ const removeBlockedBy = update
+ ? [...currentBlockers].filter((id) => !desiredSet.has(id))
+ : [];
+
+ if (addBlockedBy.length > 0 || removeBlockedBy.length > 0) {
+ try {
+ await service.update({
+ id: issue.id,
+ ...(addBlockedBy.length > 0 && { add_blocked_by: addBlockedBy }),
+ ...(removeBlockedBy.length > 0 && {
+ remove_blocked_by: removeBlockedBy,
+ }),
+ });
+
+ const nextBlockedBy = [
+ ...current.blockedBy.filter((id) => !removeBlockedBy.includes(id)),
+ ...addBlockedBy,
+ ];
+ current.blockedBy = Array.from(new Set(nextBlockedBy));
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ relationshipWarnings.push(
+ `Issue ${issue.id}: could not update blockers: ${message}`,
+ );
+ }
+ }
+ }
+
+ console.log(
+ `Beads: Imported ${created}, updated ${updated} task(s) from ${colors.cyan}${resolvedPath}${colors.reset}`,
+ );
+ if (skipped > 0) {
+ console.log(
+ `Skipped ${skipped} existing task(s) (use --update to refresh)`,
+ );
+ }
+
+ if (relationshipWarnings.length > 0) {
+ printBeadsWarnings(relationshipWarnings);
+ }
+}
+
+function selectBeadsIssues(
+ issues: ParsedBeadsIssue[],
+ requestedIssueIds: string[],
+): ParsedBeadsIssue[] {
+ const normalizedRequested = Array.from(
+ new Set(requestedIssueIds.map((id) => id.trim()).filter(Boolean)),
+ );
+
+ if (normalizedRequested.length === 0) {
+ return issues;
+ }
+
+ const issueById = new Map(issues.map((issue) => [issue.id, issue]));
+ const missingIssueIds = normalizedRequested.filter(
+ (id) => !issueById.has(id),
+ );
+ if (missingIssueIds.length > 0) {
+ throw new Error(
+ `Beads issue id(s) not found in export: ${missingIssueIds.join(", ")}`,
+ );
+ }
+
+ const childrenByParent = new Map();
+ for (const issue of issues) {
+ if (!issue.parentId) continue;
+ const children = childrenByParent.get(issue.parentId) ?? [];
+ children.push(issue.id);
+ childrenByParent.set(issue.parentId, children);
+ }
+
+ const selectedIds = new Set();
+ const queue = [...normalizedRequested];
+ while (queue.length > 0) {
+ const issueId = queue.shift();
+ if (!issueId || selectedIds.has(issueId)) continue;
+
+ selectedIds.add(issueId);
+ const childIds = childrenByParent.get(issueId) ?? [];
+ queue.push(...childIds);
+ }
+
+ return issues.filter((issue) => selectedIds.has(issue.id));
+}
+
+function printBeadsWarnings(warnings: string[]): void {
+ const maxWarnings = 20;
+ console.log(
+ `${colors.yellow}Warnings:${colors.reset} ${warnings.length} encountered during Beads import`,
+ );
+ const shown = warnings.slice(0, maxWarnings);
+ for (const warning of shown) {
+ console.log(` - ${warning}`);
+ }
+ if (warnings.length > maxWarnings) {
+ console.log(` - ...and ${warnings.length - maxWarnings} more`);
+ }
+}
+
// ============================================================
// GitHub Import Functions
// ============================================================
diff --git a/src/core/beads/fixtures.test.ts b/src/core/beads/fixtures.test.ts
new file mode 100644
index 0000000..2e07473
--- /dev/null
+++ b/src/core/beads/fixtures.test.ts
@@ -0,0 +1,19 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { describe, it, expect } from "vitest";
+
+const FIXTURE_FILES = ["basic.jsonl", "graph.jsonl", "edge-cases.jsonl"];
+
+describe("beads fixtures hygiene", () => {
+ it("does not contain obvious sensitive patterns", () => {
+ const fixturesDir = path.resolve(import.meta.dirname, "fixtures");
+ const content = FIXTURE_FILES.map((file) =>
+ fs.readFileSync(path.join(fixturesDir, file), "utf-8"),
+ ).join("\n");
+
+ expect(content).not.toMatch(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
+ expect(content).not.toContain("/Users/");
+ expect(content).not.toContain("github.com/");
+ expect(content).not.toContain("ghp_");
+ });
+});
diff --git a/src/core/beads/fixtures/README.md b/src/core/beads/fixtures/README.md
new file mode 100644
index 0000000..dcfdcef
--- /dev/null
+++ b/src/core/beads/fixtures/README.md
@@ -0,0 +1,14 @@
+# Beads Fixtures
+
+These fixtures are anonymized and derived from real-world Beads state.
+
+## Files
+
+- `basic.jsonl` - small representative sample for smoke tests
+- `graph.jsonl` - includes parent-child and blocks relationships
+- `edge-cases.jsonl` - includes non-ideal relationships (e.g. missing targets)
+
+## Generation
+
+Initial generation was done via a one-off agent prompt to pull all local Beads
+state and anonymize it
diff --git a/src/core/beads/fixtures/basic.jsonl b/src/core/beads/fixtures/basic.jsonl
new file mode 100644
index 0000000..64e0f7b
--- /dev/null
+++ b/src/core/beads/fixtures/basic.jsonl
@@ -0,0 +1,10 @@
+{"id":"beads-001","title":"Issue beads-001","description":"Redacted description for beads-001.","status":"closed","priority":0,"issue_type":"epic","created_at":"2024-02-19T09:07:44.000Z","updated_at":"2024-05-02T01:03:42.000Z","closed_at":"2024-05-02T01:03:42.000Z","close_reason":"Redacted close reason"}
+{"id":"beads-002","title":"Issue beads-002","description":"Redacted description for beads-002.","status":"closed","priority":1,"issue_type":"task","created_at":"2024-05-03T01:49:00.000Z","updated_at":"2024-05-03T02:01:32.000Z","closed_at":"2024-05-03T02:01:32.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-002","depends_on_id":"beads-003","type":"blocks","created_at":"2024-05-02T17:49:54.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"}
+{"id":"beads-003","title":"Issue beads-003","description":"Redacted description for beads-003.","status":"closed","priority":2,"issue_type":"epic","created_at":"2024-05-03T01:49:55.000Z","updated_at":"2024-05-03T02:01:31.000Z","closed_at":"2024-05-03T02:01:31.000Z","close_reason":"Redacted close reason"}
+{"id":"beads-004","title":"Issue beads-004","description":"Redacted description for beads-004.","status":"hooked","priority":1,"issue_type":"task","created_at":"2024-05-03T01:49:00.000Z","updated_at":"2024-05-03T01:49:39.000Z","dependencies":[{"issue_id":"beads-004","depends_on_id":"beads-005","type":"blocks","created_at":"2024-05-02T17:49:38.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"}
+{"id":"beads-005","title":"Issue beads-005","description":"Redacted description for beads-005.","status":"open","priority":2,"issue_type":"epic","created_at":"2024-05-03T01:49:38.000Z","updated_at":"2024-05-03T01:49:38.000Z"}
+{"id":"beads-006","title":"Issue beads-006","description":"Redacted description for beads-006.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-03T00:55:29.000Z","updated_at":"2024-05-03T01:28:08.000Z","closed_at":"2024-05-03T01:27:00.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-006","depends_on_id":"beads-007","type":"blocks","created_at":"2024-05-02T17:26:17.000Z","created_by":"user-005"}],"created_by":"user-004","owner":"user-002","assignee":"user-003"}
+{"id":"beads-007","title":"Issue beads-007","description":"Redacted description for beads-007.","status":"closed","priority":2,"issue_type":"epic","created_at":"2024-05-03T01:26:17.000Z","updated_at":"2024-05-03T01:28:08.000Z","closed_at":"2024-05-03T01:28:08.000Z","close_reason":"Redacted close reason"}
+{"id":"beads-008","title":"Issue beads-008","description":"Redacted description for beads-008.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:59:02.000Z","updated_at":"2024-05-03T00:25:48.000Z","closed_at":"2024-05-03T00:24:21.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-008","depends_on_id":"external-001","type":"blocks","created_at":"2024-05-02T16:17:31.000Z","created_by":"user-001"}],"created_by":"user-006","owner":"user-002","assignee":"user-004"}
+{"id":"beads-009","title":"Issue beads-009","description":"Redacted description for beads-009.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:37:06.000Z","updated_at":"2024-05-02T23:20:50.000Z","closed_at":"2024-05-02T23:16:02.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-009","depends_on_id":"external-002","type":"blocks","created_at":"2024-05-02T15:11:16.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"}
+{"id":"beads-010","title":"Issue beads-010","description":"Redacted description for beads-010.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:28:15.000Z","updated_at":"2024-05-02T22:48:07.000Z","closed_at":"2024-05-02T22:40:06.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-010","depends_on_id":"external-003","type":"blocks","created_at":"2024-05-02T14:32:20.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-007"}
diff --git a/src/core/beads/fixtures/edge-cases.jsonl b/src/core/beads/fixtures/edge-cases.jsonl
new file mode 100644
index 0000000..fe30f95
--- /dev/null
+++ b/src/core/beads/fixtures/edge-cases.jsonl
@@ -0,0 +1,8 @@
+{"id":"beads-002","title":"Issue beads-002","description":"Redacted description for beads-002.","status":"closed","priority":1,"issue_type":"task","created_at":"2024-05-03T01:49:00.000Z","updated_at":"2024-05-03T02:01:32.000Z","closed_at":"2024-05-03T02:01:32.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-002","depends_on_id":"beads-003","type":"blocks","created_at":"2024-05-02T17:49:54.000Z","created_by":"user-001"},{"issue_id":"beads-002","depends_on_id":"external-999","type":"blocks","created_at":"2024-05-03T02:01:32.000Z","created_by":"user-999"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"}
+{"id":"beads-004","title":"Issue beads-004","description":"Redacted description for beads-004.","status":"hooked","priority":1,"issue_type":"task","created_at":"2024-05-03T01:49:00.000Z","updated_at":"2024-05-03T01:49:39.000Z","dependencies":[{"issue_id":"beads-004","depends_on_id":"beads-005","type":"blocks","created_at":"2024-05-02T17:49:38.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"}
+{"id":"beads-006","title":"Issue beads-006","description":"Redacted description for beads-006.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-03T00:55:29.000Z","updated_at":"2024-05-03T01:28:08.000Z","closed_at":"2024-05-03T01:27:00.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-006","depends_on_id":"beads-007","type":"blocks","created_at":"2024-05-02T17:26:17.000Z","created_by":"user-005"}],"created_by":"user-004","owner":"user-002","assignee":"user-003"}
+{"id":"beads-008","title":"Issue beads-008","description":"Redacted description for beads-008.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:59:02.000Z","updated_at":"2024-05-03T00:25:48.000Z","closed_at":"2024-05-03T00:24:21.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-008","depends_on_id":"external-001","type":"blocks","created_at":"2024-05-02T16:17:31.000Z","created_by":"user-001"}],"created_by":"user-006","owner":"user-002","assignee":"user-004"}
+{"id":"beads-009","title":"Issue beads-009","description":"Redacted description for beads-009.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:37:06.000Z","updated_at":"2024-05-02T23:20:50.000Z","closed_at":"2024-05-02T23:16:02.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-009","depends_on_id":"external-002","type":"blocks","created_at":"2024-05-02T15:11:16.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"}
+{"id":"beads-010","title":"Issue beads-010","description":"Redacted description for beads-010.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:28:15.000Z","updated_at":"2024-05-02T22:48:07.000Z","closed_at":"2024-05-02T22:40:06.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-010","depends_on_id":"external-003","type":"blocks","created_at":"2024-05-02T14:32:20.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-007"}
+{"id":"beads-018","title":"Issue beads-018","description":"Redacted description for beads-018.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T20:56:25.000Z","updated_at":"2024-05-02T21:08:11.000Z","closed_at":"2024-05-02T21:08:11.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-018","depends_on_id":"external-004","type":"blocks","created_at":"2024-05-02T12:56:49.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"}
+{"id":"beads-019","title":"Issue beads-019","description":"Redacted description for beads-019.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T18:43:54.000Z","updated_at":"2024-05-02T19:18:30.000Z","closed_at":"2024-05-02T19:18:30.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-019","depends_on_id":"external-005","type":"blocks","created_at":"2024-05-02T10:44:02.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"}
diff --git a/src/core/beads/fixtures/graph.jsonl b/src/core/beads/fixtures/graph.jsonl
new file mode 100644
index 0000000..61c7866
--- /dev/null
+++ b/src/core/beads/fixtures/graph.jsonl
@@ -0,0 +1,20 @@
+{"id":"beads-002","title":"Issue beads-002","description":"Redacted description for beads-002.","status":"closed","priority":1,"issue_type":"task","created_at":"2024-05-03T01:49:00.000Z","updated_at":"2024-05-03T02:01:32.000Z","closed_at":"2024-05-03T02:01:32.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-002","depends_on_id":"beads-003","type":"blocks","created_at":"2024-05-02T17:49:54.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"}
+{"id":"beads-003","title":"Issue beads-003","description":"Redacted description for beads-003.","status":"closed","priority":2,"issue_type":"epic","created_at":"2024-05-03T01:49:55.000Z","updated_at":"2024-05-03T02:01:31.000Z","closed_at":"2024-05-03T02:01:31.000Z","close_reason":"Redacted close reason"}
+{"id":"beads-004","title":"Issue beads-004","description":"Redacted description for beads-004.","status":"hooked","priority":1,"issue_type":"task","created_at":"2024-05-03T01:49:00.000Z","updated_at":"2024-05-03T01:49:39.000Z","dependencies":[{"issue_id":"beads-004","depends_on_id":"beads-005","type":"blocks","created_at":"2024-05-02T17:49:38.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"}
+{"id":"beads-005","title":"Issue beads-005","description":"Redacted description for beads-005.","status":"open","priority":2,"issue_type":"epic","created_at":"2024-05-03T01:49:38.000Z","updated_at":"2024-05-03T01:49:38.000Z"}
+{"id":"beads-006","title":"Issue beads-006","description":"Redacted description for beads-006.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-03T00:55:29.000Z","updated_at":"2024-05-03T01:28:08.000Z","closed_at":"2024-05-03T01:27:00.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-006","depends_on_id":"beads-007","type":"blocks","created_at":"2024-05-02T17:26:17.000Z","created_by":"user-005"}],"created_by":"user-004","owner":"user-002","assignee":"user-003"}
+{"id":"beads-007","title":"Issue beads-007","description":"Redacted description for beads-007.","status":"closed","priority":2,"issue_type":"epic","created_at":"2024-05-03T01:26:17.000Z","updated_at":"2024-05-03T01:28:08.000Z","closed_at":"2024-05-03T01:28:08.000Z","close_reason":"Redacted close reason"}
+{"id":"beads-008","title":"Issue beads-008","description":"Redacted description for beads-008.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:59:02.000Z","updated_at":"2024-05-03T00:25:48.000Z","closed_at":"2024-05-03T00:24:21.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-008","depends_on_id":"external-001","type":"blocks","created_at":"2024-05-02T16:17:31.000Z","created_by":"user-001"}],"created_by":"user-006","owner":"user-002","assignee":"user-004"}
+{"id":"beads-009","title":"Issue beads-009","description":"Redacted description for beads-009.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:37:06.000Z","updated_at":"2024-05-02T23:20:50.000Z","closed_at":"2024-05-02T23:16:02.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-009","depends_on_id":"external-002","type":"blocks","created_at":"2024-05-02T15:11:16.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"}
+{"id":"beads-010","title":"Issue beads-010","description":"Redacted description for beads-010.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:28:15.000Z","updated_at":"2024-05-02T22:48:07.000Z","closed_at":"2024-05-02T22:40:06.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-010","depends_on_id":"external-003","type":"blocks","created_at":"2024-05-02T14:32:20.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-007"}
+{"id":"beads-018","title":"Issue beads-018","description":"Redacted description for beads-018.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T20:56:25.000Z","updated_at":"2024-05-02T21:08:11.000Z","closed_at":"2024-05-02T21:08:11.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-018","depends_on_id":"external-004","type":"blocks","created_at":"2024-05-02T12:56:49.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"}
+{"id":"beads-019","title":"Issue beads-019","description":"Redacted description for beads-019.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T18:43:54.000Z","updated_at":"2024-05-02T19:18:30.000Z","closed_at":"2024-05-02T19:18:30.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-019","depends_on_id":"external-005","type":"blocks","created_at":"2024-05-02T10:44:02.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"}
+{"id":"beads-020","title":"Issue beads-020","description":"Redacted description for beads-020.","status":"closed","priority":1,"issue_type":"task","created_at":"2024-05-02T03:55:25.000Z","updated_at":"2024-05-02T03:58:59.000Z","closed_at":"2024-05-02T03:58:07.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-020","depends_on_id":"external-006","type":"blocks","created_at":"2024-05-01T19:55:53.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"}
+{"id":"beads-021","title":"Issue beads-021","description":"Redacted description for beads-021.","status":"closed","priority":1,"issue_type":"task","created_at":"2024-05-02T03:55:24.000Z","updated_at":"2024-05-02T03:59:14.000Z","closed_at":"2024-05-02T03:58:39.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-021","depends_on_id":"external-007","type":"blocks","created_at":"2024-05-01T19:55:44.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"}
+{"id":"beads-024","title":"Issue beads-024","description":"Redacted description for beads-024.","status":"closed","priority":1,"issue_type":"feature","created_at":"2024-04-30T23:54:55.000Z","updated_at":"2024-05-02T21:37:50.000Z","closed_at":"2024-05-02T21:37:50.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-024","depends_on_id":"external-008","type":"blocks","created_at":"2024-05-02T13:24:12.000Z","created_by":"user-005"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"}
+{"id":"beads-025","title":"Issue beads-025","description":"Redacted description for beads-025.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-04-30T23:11:38.000Z","updated_at":"2024-05-01T21:24:03.000Z","closed_at":"2024-05-01T21:22:14.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-025","depends_on_id":"external-009","type":"blocks","created_at":"2024-05-01T13:20:21.000Z","created_by":"user-001"}],"created_by":"user-006","owner":"user-002","assignee":"user-004"}
+{"id":"beads-031","title":"Issue beads-031","description":"Redacted description for beads-031.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-02-20T00:24:12.000Z","updated_at":"2024-05-02T19:58:44.000Z","closed_at":"2024-05-02T19:36:07.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-031","depends_on_id":"beads-032","type":"blocks","created_at":"2024-02-20T00:25:56.000Z","created_by":"user-001"},{"issue_id":"beads-031","depends_on_id":"external-010","type":"blocks","created_at":"2024-05-02T11:34:08.000Z","created_by":"user-001"}],"assignee":"user-004"}
+{"id":"beads-032","title":"Issue beads-032","description":"Redacted description for beads-032.","status":"closed","priority":2,"issue_type":"epic","created_at":"2024-02-20T00:25:48.000Z","updated_at":"2024-05-02T01:03:42.000Z","closed_at":"2024-05-02T01:03:42.000Z","close_reason":"Redacted close reason"}
+{"id":"beads-033","title":"Issue beads-033","description":"Redacted description for beads-033.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-02-20T00:24:11.000Z","updated_at":"2024-05-02T19:58:35.000Z","closed_at":"2024-05-02T19:35:31.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-033","depends_on_id":"beads-032","type":"blocks","created_at":"2024-02-20T00:25:56.000Z","created_by":"user-001"},{"issue_id":"beads-033","depends_on_id":"external-011","type":"blocks","created_at":"2024-05-02T11:34:30.000Z","created_by":"user-001"}],"assignee":"user-007"}
+{"id":"beads-034","title":"Issue beads-034","description":"Redacted description for beads-034.","status":"closed","priority":1,"issue_type":"task","created_at":"2024-02-20T00:24:07.000Z","updated_at":"2024-05-02T22:17:39.000Z","closed_at":"2024-05-02T22:17:39.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-034","depends_on_id":"beads-032","type":"blocks","created_at":"2024-02-20T00:25:56.000Z","created_by":"user-001"},{"issue_id":"beads-034","depends_on_id":"external-012","type":"blocks","created_at":"2024-05-02T14:00:50.000Z","created_by":"user-001"}],"assignee":"user-004"}
+{"id":"beads-035","title":"Issue beads-035","description":"Redacted description for beads-035.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-02-17T22:58:15.000Z","updated_at":"2024-05-02T22:30:28.000Z","closed_at":"2024-05-02T22:29:53.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-035","depends_on_id":"external-013","type":"blocks","created_at":"2024-05-02T14:22:23.000Z","created_by":"user-001"}],"assignee":"user-007"}
diff --git a/src/core/beads/import.test.ts b/src/core/beads/import.test.ts
new file mode 100644
index 0000000..ce460b3
--- /dev/null
+++ b/src/core/beads/import.test.ts
@@ -0,0 +1,132 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { describe, it, expect } from "vitest";
+import { parseBeadsExportJsonl } from "./import.js";
+
+function fixturePath(name: string): string {
+ return path.resolve(import.meta.dirname, "fixtures", name);
+}
+
+describe("parseBeadsExportJsonl", () => {
+ it("parses Beads JSONL and maps relationships", () => {
+ const input = [
+ JSON.stringify({
+ id: "bd-1",
+ title: "Parent",
+ description: "Parent issue",
+ status: "open",
+ priority: 1,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T01:00:00Z",
+ }),
+ JSON.stringify({
+ id: "bd-2",
+ title: "Child",
+ description: "Child issue",
+ status: "in_progress",
+ priority: 2,
+ created_at: "2026-01-01T02:00:00Z",
+ updated_at: "2026-01-01T03:00:00Z",
+ dependencies: [
+ { issue_id: "bd-2", depends_on_id: "bd-1", type: "parent-child" },
+ { issue_id: "bd-2", depends_on_id: "bd-1", type: "blocks" },
+ ],
+ }),
+ ].join("\n");
+
+ const parsed = parseBeadsExportJsonl(input);
+ expect(parsed.issues).toHaveLength(2);
+ expect(parsed.warnings).toEqual([]);
+
+ const child = parsed.issues.find((issue) => issue.id === "bd-2");
+ expect(child).toBeDefined();
+ expect(child?.parentId).toBe("bd-1");
+ expect(child?.blockerIds).toEqual(["bd-1"]);
+ expect(child?.started_at).toBe("2026-01-01T03:00:00Z");
+ expect(child?.beadsMetadata.status).toBe("in_progress");
+ });
+
+ it("parses records containing embedded Issue objects", () => {
+ const input = JSON.stringify({
+ Issue: {
+ id: "bd-3",
+ title: "Embedded",
+ description: "Embedded format",
+ status: "closed",
+ priority: 0,
+ created_at: "2026-02-01T00:00:00Z",
+ updated_at: "2026-02-01T01:00:00Z",
+ closed_at: "2026-02-01T01:00:00Z",
+ },
+ dependency_count: 0,
+ dependent_count: 0,
+ });
+
+ const parsed = parseBeadsExportJsonl(input);
+ expect(parsed.issues).toHaveLength(1);
+ expect(parsed.issues[0].id).toBe("bd-3");
+ expect(parsed.issues[0].completed).toBe(true);
+ expect(parsed.issues[0].result).toBe("Imported as completed from Beads");
+ });
+
+ it("prefers depends_on.id over dependency row id", () => {
+ const input = [
+ JSON.stringify({
+ id: "bd-parent",
+ title: "Parent",
+ status: "open",
+ priority: 1,
+ }),
+ JSON.stringify({
+ id: "bd-child",
+ title: "Child",
+ status: "open",
+ priority: 1,
+ dependencies: [
+ {
+ id: "dep-row-123",
+ issue_id: "bd-child",
+ type: "blocks",
+ depends_on: { id: "bd-parent" },
+ },
+ ],
+ }),
+ ].join("\n");
+
+ const parsed = parseBeadsExportJsonl(input);
+ const child = parsed.issues.find((issue) => issue.id === "bd-child");
+ expect(child?.blockerIds).toEqual(["bd-parent"]);
+ });
+
+ it("throws on malformed JSON with line number", () => {
+ expect(() =>
+ parseBeadsExportJsonl('{"id":"bd-1","title":"ok"}\n{"bad"'),
+ ).toThrow(/Invalid JSON on line 2/);
+ });
+
+ it("throws on duplicate issue ids", () => {
+ const duplicate = [
+ JSON.stringify({ id: "dup-1", title: "A" }),
+ JSON.stringify({ id: "dup-1", title: "B" }),
+ ].join("\n");
+
+ expect(() => parseBeadsExportJsonl(duplicate)).toThrow(
+ /Duplicate issue id in input: dup-1/,
+ );
+ });
+
+ it("loads anonymized fixtures generated from local Beads state", () => {
+ const graphFixture = fs.readFileSync(fixturePath("graph.jsonl"), "utf-8");
+ const parsed = parseBeadsExportJsonl(graphFixture);
+
+ expect(parsed.issues.length).toBeGreaterThan(0);
+
+ const hasBlocks = parsed.issues.some(
+ (issue) => issue.blockerIds.length > 0,
+ );
+ expect(hasBlocks).toBe(true);
+
+ const hasClosed = parsed.issues.some((issue) => issue.completed);
+ expect(hasClosed).toBe(true);
+ });
+});
diff --git a/src/core/beads/import.ts b/src/core/beads/import.ts
new file mode 100644
index 0000000..0188109
--- /dev/null
+++ b/src/core/beads/import.ts
@@ -0,0 +1,259 @@
+import type { BeadsMetadata } from "../../types.js";
+
+export interface ParsedBeadsIssue {
+ id: string;
+ name: string;
+ description: string;
+ priority: number;
+ completed: boolean;
+ result: string | null;
+ created_at?: string;
+ updated_at?: string;
+ started_at?: string | null;
+ completed_at?: string | null;
+ parentId?: string;
+ blockerIds: string[];
+ beadsMetadata: BeadsMetadata;
+}
+
+export interface ParsedBeadsImport {
+ issues: ParsedBeadsIssue[];
+ warnings: string[];
+}
+
+interface NormalizedDependency {
+ issueId: string;
+ dependsOnId: string;
+ type: string;
+}
+
+function asRecord(value: unknown): Record | null {
+ if (typeof value !== "object" || value === null) return null;
+ return value as Record;
+}
+
+function getString(
+ record: Record,
+ key: string,
+): string | undefined {
+ const value = record[key];
+ if (typeof value !== "string") return undefined;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+}
+
+function getNumber(
+ record: Record,
+ key: string,
+): number | undefined {
+ const value = record[key];
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ if (typeof value === "string") {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return undefined;
+}
+
+function getStringArray(
+ record: Record,
+ key: string,
+): string[] | undefined {
+ const value = record[key];
+ if (!Array.isArray(value)) return undefined;
+ const items = value
+ .filter((v): v is string => typeof v === "string")
+ .map((v) => v.trim())
+ .filter((v) => v.length > 0);
+ return items.length > 0 ? items : undefined;
+}
+
+function dedupe(values: string[]): string[] {
+ return Array.from(new Set(values));
+}
+
+function normalizeStatus(status: string | undefined): string | undefined {
+ if (!status) return undefined;
+ return status.trim().toLowerCase();
+}
+
+function extractEmbeddedIssue(
+ record: Record,
+): Record {
+ const embedded = record.Issue;
+ const embeddedRecord = asRecord(embedded);
+ if (!embeddedRecord) return record;
+
+ // Prefer embedded issue fields but allow top-level fallback.
+ return {
+ ...record,
+ ...embeddedRecord,
+ };
+}
+
+function parseDependency(
+ raw: unknown,
+ fallbackIssueId: string,
+): NormalizedDependency | null {
+ const record = asRecord(raw);
+ if (!record) return null;
+
+ const type =
+ getString(record, "type") ?? getString(record, "dependency_type");
+ if (!type) return null;
+
+ const issueId = getString(record, "issue_id") ?? fallbackIssueId;
+
+ const dependsOnRecord = asRecord(record.depends_on);
+ const dependsOnId =
+ getString(record, "depends_on_id") ??
+ (dependsOnRecord ? getString(dependsOnRecord, "id") : undefined) ??
+ getString(record, "id");
+
+ if (!issueId || !dependsOnId) return null;
+
+ return {
+ issueId,
+ dependsOnId,
+ type,
+ };
+}
+
+function parseIssueRecord(
+ lineNo: number,
+ line: string,
+): { issue: ParsedBeadsIssue | null; warnings: string[] } {
+ const warnings: string[] = [];
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(line);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ throw new Error(`Invalid JSON on line ${lineNo}: ${message}`);
+ }
+
+ const root = asRecord(parsed);
+ if (!root) {
+ warnings.push(`Line ${lineNo}: expected JSON object, skipping`);
+ return { issue: null, warnings };
+ }
+
+ const record = extractEmbeddedIssue(root);
+
+ const id = getString(record, "id");
+ const title = getString(record, "title");
+ if (!id || !title) {
+ warnings.push(`Line ${lineNo}: missing required id/title, skipping`);
+ return { issue: null, warnings };
+ }
+
+ const description = getString(record, "description") ?? "";
+ const status = normalizeStatus(getString(record, "status"));
+ const priority = Math.max(
+ 0,
+ Math.min(100, Math.trunc(getNumber(record, "priority") ?? 1)),
+ );
+
+ const createdAt = getString(record, "created_at");
+ const updatedAt = getString(record, "updated_at");
+ const closedAt = getString(record, "closed_at");
+
+ const completed = status === "closed" || Boolean(closedAt);
+ const closeReason = getString(record, "close_reason");
+ const result = completed
+ ? (closeReason ?? "Imported as completed from Beads")
+ : null;
+
+ const startedAt =
+ status === "in_progress" || status === "hooked"
+ ? (updatedAt ?? createdAt ?? null)
+ : null;
+
+ const depsRaw = Array.isArray(record.dependencies) ? record.dependencies : [];
+ const normalizedDeps = depsRaw
+ .map((dep) => parseDependency(dep, id))
+ .filter((dep): dep is NormalizedDependency => dep !== null);
+
+ const parentCandidates = dedupe(
+ normalizedDeps
+ .filter((dep) => dep.type === "parent-child")
+ .map((dep) => dep.dependsOnId),
+ );
+ if (parentCandidates.length > 1) {
+ warnings.push(
+ `Issue ${id}: multiple parent-child dependencies found (${parentCandidates.join(", ")}), using ${parentCandidates[0]}`,
+ );
+ }
+
+ const blockerIds = dedupe(
+ normalizedDeps
+ .filter((dep) => dep.type === "blocks")
+ .map((dep) => dep.dependsOnId),
+ );
+
+ const labels = getStringArray(record, "labels");
+ const dependencyTypes = dedupe(normalizedDeps.map((dep) => dep.type));
+
+ const beadsMetadata: BeadsMetadata = {
+ issueId: id,
+ ...(status && { status }),
+ ...(getString(record, "issue_type") && {
+ issueType: getString(record, "issue_type"),
+ }),
+ ...(getString(record, "source_system") && {
+ sourceSystem: getString(record, "source_system"),
+ }),
+ ...(getString(record, "external_ref") && {
+ externalRef: getString(record, "external_ref"),
+ }),
+ ...(labels && { labels }),
+ ...(parentCandidates[0] && { parentId: parentCandidates[0] }),
+ ...(blockerIds.length > 0 && { blockerIds }),
+ ...(dependencyTypes.length > 0 && { dependencyTypes }),
+ };
+
+ return {
+ issue: {
+ id,
+ name: title,
+ description,
+ priority,
+ completed,
+ result,
+ created_at: createdAt,
+ updated_at: updatedAt,
+ started_at: startedAt,
+ completed_at: closedAt ?? null,
+ parentId: parentCandidates[0],
+ blockerIds,
+ beadsMetadata,
+ },
+ warnings,
+ };
+}
+
+export function parseBeadsExportJsonl(input: string): ParsedBeadsImport {
+ const warnings: string[] = [];
+ const issues: ParsedBeadsIssue[] = [];
+ const seen = new Set();
+
+ const lines = input.split(/\r?\n/);
+ for (let i = 0; i < lines.length; i++) {
+ const lineNo = i + 1;
+ const line = lines[i].trim();
+ if (!line) continue;
+
+ const { issue, warnings: lineWarnings } = parseIssueRecord(lineNo, line);
+ warnings.push(...lineWarnings);
+ if (!issue) continue;
+
+ if (seen.has(issue.id)) {
+ throw new Error(`Duplicate issue id in input: ${issue.id}`);
+ }
+ seen.add(issue.id);
+ issues.push(issue);
+ }
+
+ return { issues, warnings };
+}
diff --git a/src/core/beads/index.ts b/src/core/beads/index.ts
new file mode 100644
index 0000000..3f9be6a
--- /dev/null
+++ b/src/core/beads/index.ts
@@ -0,0 +1,5 @@
+export {
+ parseBeadsExportJsonl,
+ type ParsedBeadsIssue,
+ type ParsedBeadsImport,
+} from "./import.js";
diff --git a/src/core/task-service.test.ts b/src/core/task-service.test.ts
index 0ecf6b9..5f51dda 100644
--- a/src/core/task-service.test.ts
+++ b/src/core/task-service.test.ts
@@ -125,6 +125,46 @@ describe("TaskService", () => {
expect(updated.priority).toBe(10);
});
+ it("preserves explicit completed_at when marking task completed", async () => {
+ const task = await service.create({
+ name: "Test",
+ description: "Description",
+ });
+
+ const updated = await service.update({
+ id: task.id,
+ completed: true,
+ completed_at: "2026-01-02T03:04:05Z",
+ });
+
+ expect(updated.completed).toBe(true);
+ expect(new Date(updated.completed_at ?? "").toISOString()).toBe(
+ "2026-01-02T03:04:05.000Z",
+ );
+ });
+
+ it("clears completed_at when reopening, even if completed_at is provided", async () => {
+ const task = await service.create({
+ name: "Test",
+ description: "Description",
+ });
+
+ await service.update({
+ id: task.id,
+ completed: true,
+ completed_at: "2026-01-02T03:04:05Z",
+ });
+
+ const reopened = await service.update({
+ id: task.id,
+ completed: false,
+ completed_at: "2027-02-03T04:05:06Z",
+ });
+
+ expect(reopened.completed).toBe(false);
+ expect(reopened.completed_at).toBeNull();
+ });
+
it("throws when task does not exist", async () => {
await expect(
service.update({
diff --git a/src/core/task-service.ts b/src/core/task-service.ts
index 1317997..ae84449 100644
--- a/src/core/task-service.ts
+++ b/src/core/task-service.ts
@@ -407,9 +407,15 @@ export class TaskService {
if (input.completed !== undefined) {
// Handle completed_at timestamp based on completion transition
if (input.completed && !task.completed) {
- task.completed_at = now;
+ task.completed_at = input.completed_at ?? now;
} else if (!input.completed && task.completed) {
task.completed_at = null;
+ } else if (
+ input.completed &&
+ task.completed &&
+ input.completed_at !== undefined
+ ) {
+ task.completed_at = input.completed_at;
}
task.completed = input.completed;
}
diff --git a/src/types.test.ts b/src/types.test.ts
index 672252f..70d4380 100644
--- a/src/types.test.ts
+++ b/src/types.test.ts
@@ -314,6 +314,60 @@ describe("TaskSchema migrations", () => {
expect(task.metadata?.commit?.sha).toBe("abc123");
});
});
+
+ describe("beads metadata compatibility", () => {
+ it("accepts beads metadata on tasks", () => {
+ const taskWithBeadsMetadata = {
+ id: "beads-task-1",
+ name: "Imported from Beads",
+ description: "Imported description",
+ priority: 2,
+ completed: false,
+ result: null,
+ metadata: {
+ beads: {
+ issueId: "beads-task-1",
+ status: "open",
+ issueType: "task",
+ blockerIds: ["beads-task-2"],
+ },
+ },
+ created_at: "2026-01-01T00:00:00.000Z",
+ updated_at: "2026-01-01T00:00:00.000Z",
+ completed_at: null,
+ };
+
+ const task = TaskSchema.parse(taskWithBeadsMetadata);
+ expect(task.metadata?.beads?.issueId).toBe("beads-task-1");
+ expect(task.metadata?.beads?.status).toBe("open");
+ expect(task.metadata?.beads?.blockerIds).toEqual(["beads-task-2"]);
+ });
+
+ it("preserves backward compatibility for tasks without beads metadata", () => {
+ const taskWithoutBeads = {
+ id: "legacy-no-beads",
+ name: "Legacy task",
+ description: "Still valid",
+ priority: 1,
+ completed: false,
+ result: null,
+ metadata: {
+ github: {
+ issueNumber: 42,
+ issueUrl: "https://github.com/example/repo/issues/42",
+ repo: "example/repo",
+ },
+ },
+ created_at: "2026-01-01T00:00:00.000Z",
+ updated_at: "2026-01-01T00:00:00.000Z",
+ completed_at: null,
+ };
+
+ const task = TaskSchema.parse(taskWithoutBeads);
+ expect(task.metadata?.github?.issueNumber).toBe(42);
+ expect(task.metadata?.beads).toBeUndefined();
+ });
+ });
});
describe("ArchivedTaskSchema migrations", () => {
diff --git a/src/types.ts b/src/types.ts
index 58f5f3d..923dad1 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -37,11 +37,26 @@ export const ShortcutMetadataSchema = z.object({
export type ShortcutMetadata = z.infer;
+export const BeadsMetadataSchema = z.object({
+ issueId: z.string().min(1),
+ status: z.string().min(1).optional(),
+ issueType: z.string().min(1).optional(),
+ sourceSystem: z.string().min(1).optional(),
+ externalRef: z.string().min(1).optional(),
+ labels: z.array(z.string().min(1)).optional(),
+ parentId: z.string().min(1).optional(),
+ blockerIds: z.array(z.string().min(1)).optional(),
+ dependencyTypes: z.array(z.string().min(1)).optional(),
+});
+
+export type BeadsMetadata = z.infer;
+
export const TaskMetadataSchema = z
.object({
commit: CommitMetadataSchema.optional(),
github: GithubMetadataSchema.optional(),
shortcut: ShortcutMetadataSchema.optional(),
+ beads: BeadsMetadataSchema.optional(),
})
.nullable();
@@ -186,6 +201,7 @@ export const UpdateTaskInputSchema = z.object({
.optional(),
metadata: TaskMetadataSchema.nullable().optional(),
started_at: flexibleDatetime().nullable().optional(),
+ completed_at: flexibleDatetime().nullable().optional(),
delete: z.boolean().optional(),
add_blocked_by: z.array(z.string().min(1)).optional(),
remove_blocked_by: z.array(z.string().min(1)).optional(),