trekoon 0.3.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: trekoon
3
- description: Use for Trekoon-based planning and execution: creating epics, tasks, and subtasks; breaking work into dependency-aware graphs; checking status and progress; planning backlog or sprint work; and coordinating agent execution from Trekoon. Prefer this whenever the user wants tracked implementation planning or Trekoon entity management, even if they do not explicitly say "Trekoon."
3
+ description: "Use for Trekoon-based planning and execution: creating epics, tasks, and subtasks; breaking work into dependency-aware graphs; checking status and progress; planning backlog or sprint work; and coordinating agent execution from Trekoon. Prefer this whenever the user wants tracked implementation planning or Trekoon entity management, even if they do not explicitly say \"Trekoon\"."
4
4
  ---
5
5
 
6
6
  # Trekoon Skill
package/README.md CHANGED
@@ -211,6 +211,7 @@ overview, kanban workspace per epic, task detail modals, and search.
211
211
  | Start an agent session | `trekoon session --epic <id>` |
212
212
  | Get next-action suggestions | `trekoon suggest --epic <id>` |
213
213
  | Check epic progress | `trekoon epic progress <id>` |
214
+ | Export epic to Markdown | `trekoon epic export <id>` |
214
215
  | Mark a task done | `trekoon task done <id>` |
215
216
  | Sync across worktrees | `trekoon sync pull --from main` |
216
217
  | Get help | `trekoon [command] -h` |
package/docs/ai-agents.md CHANGED
@@ -200,6 +200,7 @@ Use the narrowest command that answers the question:
200
200
  | A few ready options | `trekoon --toon task ready --limit 5` |
201
201
  | One task with subtasks | `trekoon --toon task show <task-id> --all` |
202
202
  | One epic tree | `trekoon --toon epic show <epic-id> --all` |
203
+ | Export epic to Markdown | `trekoon --toon epic export <epic-id>` |
203
204
  | Repeated text in one scope | `trekoon --toon epic|task|subtask search ...` |
204
205
 
205
206
  For repeated text changes, use the safe replace loop:
package/docs/commands.md CHANGED
@@ -202,6 +202,24 @@ trekoon epic progress <epic-id>
202
202
  Returns status counts (`total`, `doneCount`, `inProgressCount`, `blockedCount`,
203
203
  `todoCount`), `readyCount`, and `nextCandidate`.
204
204
 
205
+ ## Epic export
206
+
207
+ ```bash
208
+ trekoon epic export <epic-id> [--path <path>] [--overwrite]
209
+ ```
210
+
211
+ Writes a Markdown snapshot of the epic including tasks, subtasks, dependencies,
212
+ external node stubs, and warnings. The output is a point-in-time artifact; the
213
+ database remains the source of truth.
214
+
215
+ - Default path: `<worktree-root>/plans/<slugified-title>.md`
216
+ - `--path` with a file extension (e.g. `docs/plan.md`) creates that exact file
217
+ - `--path` without an extension (e.g. `docs/plans`) creates the default-named file inside that directory
218
+ - `--overwrite` resaves when the file already exists
219
+
220
+ Returns `epicId`, `path`, `overwritten`, and `summary` counts in structured
221
+ output.
222
+
205
223
  ## Session scoping
206
224
 
207
225
  ```bash
@@ -134,6 +134,20 @@ Cascades atomically through all descendants. If any descendant has an unresolved
134
134
  external dependency, the whole update fails with no partial writes. Works with
135
135
  `--status done` and `--status todo` only.
136
136
 
137
+ ## Export an epic to Markdown
138
+
139
+ ```bash
140
+ trekoon epic export <epic-id>
141
+ trekoon epic export <epic-id> --path docs/plan.md # exact file
142
+ trekoon epic export <epic-id> --path docs/plans # default name inside dir
143
+ trekoon epic export <epic-id> --overwrite
144
+ ```
145
+
146
+ Writes a readable Markdown snapshot under `plans/` by default. With `--path`,
147
+ a file extension means "write this file"; no extension means "put the default-
148
+ named file in this directory". Use `--overwrite` to resave after the plan state
149
+ changes.
150
+
137
151
  ## Check progress
138
152
 
139
153
  ```bash
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.3.8",
4
- "description": "AI-first local issue tracker CLI.",
3
+ "version": "0.4.0",
4
+ "description": "AI-first task tracking that lives in your repo. You describe what to build, your agent plans it as a dependency graph, then executes it task by task",
5
5
  "keywords": [
6
6
  "ai",
7
7
  "agent",
@@ -36,7 +36,12 @@ import {
36
36
  import { formatHumanTable } from "../io/human-table";
37
37
  import { failResult, okResult } from "../io/output";
38
38
  import { type CliContext, type CliResult } from "../runtime/command-types";
39
+ import { buildEpicExportBundle } from "../export/build-epic-export-bundle";
40
+ import { resolveExportPath } from "../export/path";
41
+ import { renderMarkdown } from "../export/render-markdown";
42
+ import { atomicWrite, ExportWriteError } from "../export/write";
39
43
  import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
44
+ import { resolveStoragePaths } from "../storage/path";
40
45
 
41
46
  function formatEpic(epic: EpicRecord): string {
42
47
  return `${epic.id} | ${epic.title} | ${epic.status}`;
@@ -53,6 +58,7 @@ const SEARCH_OPTIONS = ["fields", "preview"] as const;
53
58
  const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
54
59
  const EXPAND_OPTIONS = ["task", "subtask", "dep"] as const;
55
60
  const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "t"] as const;
61
+ const EXPORT_OPTIONS = ["path", "overwrite"] as const;
56
62
  const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
57
63
 
58
64
  function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
@@ -1472,10 +1478,87 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
1472
1478
  data: { id: epicId },
1473
1479
  });
1474
1480
  }
1481
+ case "export": {
1482
+ const epicId: string = parsed.positional[1] ?? "";
1483
+ const exportUnknown = findUnknownOption(parsed, EXPORT_OPTIONS);
1484
+ if (exportUnknown !== undefined) {
1485
+ return unknownOption("epic.export", exportUnknown, EXPORT_OPTIONS);
1486
+ }
1487
+
1488
+ const missingExportOption = readMissingOptionValue(parsed.missingOptionValues, "path");
1489
+ if (missingExportOption !== undefined) {
1490
+ return failMissingOptionValue("epic.export", missingExportOption);
1491
+ }
1492
+
1493
+ if (!epicId) {
1494
+ return failResult({
1495
+ command: "epic.export",
1496
+ human: "Usage: trekoon epic export <epic-id> [--path <path>] [--overwrite]",
1497
+ data: {},
1498
+ error: { code: "invalid_input", message: "Missing epic ID" },
1499
+ });
1500
+ }
1501
+
1502
+ const bundle = buildEpicExportBundle(domain, epicId);
1503
+ const markdown = renderMarkdown(bundle);
1504
+ const storagePaths = resolveStoragePaths(context.cwd);
1505
+ const customPath = readOption(parsed.options, "path");
1506
+ const overwrite = hasFlag(parsed.flags, "overwrite");
1507
+
1508
+ const exportPath = resolveExportPath({
1509
+ customPath,
1510
+ epicId: bundle.epic.id,
1511
+ epicTitle: bundle.epic.title,
1512
+ worktreeRoot: storagePaths.worktreeRoot,
1513
+ cwd: context.cwd,
1514
+ });
1515
+
1516
+ try {
1517
+ const result = atomicWrite({ path: exportPath, content: markdown, overwrite });
1518
+ const action = result.overwritten ? "Updated" : "Exported";
1519
+ return okResult({
1520
+ command: "epic.export",
1521
+ human: `${action} epic to ${result.path}`,
1522
+ data: {
1523
+ epicId: bundle.epic.id,
1524
+ path: result.path,
1525
+ overwritten: result.overwritten,
1526
+ summary: bundle.summary,
1527
+ },
1528
+ });
1529
+ } catch (error: unknown) {
1530
+ if (error instanceof ExportWriteError) {
1531
+ return failResult({
1532
+ command: "epic.export",
1533
+ human: error.message,
1534
+ data: { path: exportPath, epicId: bundle.epic.id },
1535
+ error: { code: error.code, message: error.message },
1536
+ });
1537
+ }
1538
+ const fsCode = typeof error === "object" && error !== null && "code" in error ? (error as { code: string }).code : null;
1539
+ if (fsCode === "EACCES" || fsCode === "EPERM" || fsCode === "EROFS") {
1540
+ return failResult({
1541
+ command: "epic.export",
1542
+ human: `Permission denied: cannot write to ${exportPath}`,
1543
+ data: { path: exportPath, epicId: bundle.epic.id, fsError: fsCode },
1544
+ error: { code: "permission_denied", message: `Permission denied: ${exportPath}` },
1545
+ });
1546
+ }
1547
+ if (fsCode === "EISDIR") {
1548
+ return failResult({
1549
+ command: "epic.export",
1550
+ human: `Path is a directory, not a file: ${exportPath}`,
1551
+ data: { path: exportPath, epicId: bundle.epic.id, fsError: fsCode },
1552
+ error: { code: "invalid_path", message: `Path is a directory: ${exportPath}` },
1553
+ });
1554
+ }
1555
+ throw error;
1556
+ }
1557
+ }
1475
1558
  default:
1476
1559
  return failResult({
1477
1560
  command: "epic",
1478
- human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress>",
1561
+ human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress|export>",
1479
1562
  data: {
1480
1563
  args: context.args,
1481
1564
  },
@@ -103,7 +103,7 @@ const BOARD_HELP = [
103
103
  ].join("\n");
104
104
 
105
105
  const EPIC_HELP = [
106
- "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress> [options]",
106
+ "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress|export> [options]",
107
107
  "",
108
108
  "Create:",
109
109
  " trekoon epic create --title \"...\" --description \"...\" [--status <status>]",
@@ -149,6 +149,24 @@ const EPIC_HELP = [
149
149
  "Progress:",
150
150
  " trekoon epic progress <epic-id>",
151
151
  " Returns done/in_progress/blocked/todo counts, readyCount, and nextCandidate.",
152
+ "",
153
+ "Export:",
154
+ " trekoon epic export <epic-id> [--path <path>] [--overwrite]",
155
+ " Writes a Markdown snapshot of the epic including tasks, subtasks, dependencies,",
156
+ " external node stubs, and warnings. The Markdown is a point-in-time artifact;",
157
+ " the database remains the source of truth.",
158
+ "",
159
+ " Default path: <worktree-root>/plans/<slugified-title>.md",
160
+ " --path <path> Custom output path (relative or absolute).",
161
+ " With extension (e.g. docs/plan.md): creates that file.",
162
+ " Without extension (e.g. docs/plans): creates the default-named file inside.",
163
+ " --overwrite Resave if the file already exists.",
164
+ "",
165
+ " Examples:",
166
+ " trekoon epic export abc-123",
167
+ " trekoon epic export abc-123 --path docs/plan.md # writes docs/plan.md",
168
+ " trekoon epic export abc-123 --path docs/plans # writes docs/plans/<title>.md",
169
+ " trekoon epic export abc-123 --overwrite",
152
170
  ].join("\n");
153
171
 
154
172
  const TASK_HELP = [
@@ -59,6 +59,7 @@ const QUICKSTART_TEXT = [
59
59
  " Bulk update: trekoon --toon task update --ids id1,id2 --append \"...\" --status in_progress",
60
60
  " Ready queue: trekoon --toon task ready [--limit <n>] [--epic <id>]",
61
61
  " Next candidate: trekoon --toon task next [--epic <id>]",
62
+ " Export epic to MD: trekoon --toon epic export <epic-id> [--path <path>] [--overwrite]",
62
63
  "",
63
64
  "6) List and view defaults",
64
65
  " Default scope: open work (in_progress, todo), limit 10.",
@@ -152,6 +153,7 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
152
153
  "trekoon --toon task list --status in_progress,todo --limit 20",
153
154
  "trekoon --toon task list --cursor <n>",
154
155
  "trekoon --toon task update --ids id1,id2 --append \"...\" --status in_progress",
156
+ "trekoon --toon epic export <epic-id>",
155
157
  ],
156
158
  machineExamples: [
157
159
  "trekoon --toon quickstart",
@@ -166,6 +168,7 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
166
168
  "trekoon --toon task ready --limit 5",
167
169
  "trekoon --toon task next",
168
170
  "trekoon --toon dep reverse <task-or-subtask-id>",
171
+ "trekoon --toon epic export <epic-id>",
169
172
  ],
170
173
  wipeWarning: {
171
174
  command: "trekoon wipe --yes",
@@ -0,0 +1,178 @@
1
+ import type { TrackerDomain } from "../domain/tracker-domain";
2
+ import type { DependencyRecord, SubtaskRecord, TaskRecord } from "../domain/types";
3
+
4
+ import {
5
+ EXPORT_SCHEMA_VERSION,
6
+ type ExportBundle,
7
+ type ExportDependencyEdge,
8
+ type ExportExternalNode,
9
+ type ExportStatusCounts,
10
+ type ExportSummary,
11
+ type ExportWarning,
12
+ } from "./types";
13
+
14
+ function countStatuses(records: readonly { readonly status: string }[]): ExportStatusCounts {
15
+ const counts = { total: records.length, todo: 0, inProgress: 0, done: 0, blocked: 0, other: 0 };
16
+ for (const record of records) {
17
+ if (record.status === "todo") counts.todo += 1;
18
+ else if (record.status === "in_progress") counts.inProgress += 1;
19
+ else if (record.status === "done") counts.done += 1;
20
+ else if (record.status === "blocked") counts.blocked += 1;
21
+ else counts.other += 1;
22
+ }
23
+ return counts;
24
+ }
25
+
26
+ export function buildEpicExportBundle(domain: TrackerDomain, epicId: string): ExportBundle {
27
+ const epic = domain.getEpicOrThrow(epicId);
28
+ const tasks: readonly TaskRecord[] = domain.listTasks(epicId);
29
+ const taskIds = new Set(tasks.map((t) => t.id));
30
+
31
+ const subtasksByTaskId = domain.listSubtasksByTaskIds(tasks.map((t) => t.id));
32
+ const allSubtasks: SubtaskRecord[] = [];
33
+ for (const task of tasks) {
34
+ for (const subtask of subtasksByTaskId.get(task.id) ?? []) {
35
+ allSubtasks.push(subtask);
36
+ }
37
+ }
38
+ const subtaskIds = new Set(allSubtasks.map((s) => s.id));
39
+
40
+ const inScopeIds = new Set([...taskIds, ...subtaskIds]);
41
+
42
+ // Gather all dependencies touching any in-scope node
43
+ const sourceIds = [...inScopeIds];
44
+ const dependenciesBySourceId = domain.listDependenciesBySourceIds(sourceIds);
45
+ const allRawDeps: DependencyRecord[] = [];
46
+ const seenDepIds = new Set<string>();
47
+ for (const deps of dependenciesBySourceId.values()) {
48
+ for (const dep of deps) {
49
+ if (!seenDepIds.has(dep.id)) {
50
+ seenDepIds.add(dep.id);
51
+ allRawDeps.push(dep);
52
+ }
53
+ }
54
+ }
55
+
56
+ // Also find dependencies where in-scope nodes are the target (dependsOnId)
57
+ // by checking each in-scope node with listDependenciesTouchingNode
58
+ // We use a more efficient approach: query dependencies where dependsOnId is in scope
59
+ for (const nodeId of inScopeIds) {
60
+ const touching = domain.listDependenciesTouchingNode(nodeId);
61
+ for (const dep of touching) {
62
+ if (!seenDepIds.has(dep.id)) {
63
+ seenDepIds.add(dep.id);
64
+ allRawDeps.push(dep);
65
+ }
66
+ }
67
+ }
68
+
69
+ // Sort for stable ordering
70
+ allRawDeps.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
71
+
72
+ // Classify edges and build dependency indexes
73
+ const blockedByMap = new Map<string, string[]>();
74
+ const blocksMap = new Map<string, string[]>();
75
+ const externalNodeMap = new Map<string, ExportExternalNode>();
76
+ const warnings: ExportWarning[] = [];
77
+
78
+ const edges: ExportDependencyEdge[] = allRawDeps.map((dep) => {
79
+ const sourceInternal = inScopeIds.has(dep.sourceId);
80
+ const targetInternal = inScopeIds.has(dep.dependsOnId);
81
+ const internal = sourceInternal && targetInternal;
82
+
83
+ // Build blockedBy: source is blocked by dependsOn
84
+ if (sourceInternal) {
85
+ const existing = blockedByMap.get(dep.sourceId) ?? [];
86
+ existing.push(dep.dependsOnId);
87
+ blockedByMap.set(dep.sourceId, existing);
88
+ }
89
+
90
+ // Build blocks: dependsOn blocks source
91
+ if (targetInternal) {
92
+ const existing = blocksMap.get(dep.dependsOnId) ?? [];
93
+ existing.push(dep.sourceId);
94
+ blocksMap.set(dep.dependsOnId, existing);
95
+ }
96
+
97
+ // Resolve external nodes
98
+ if (!sourceInternal && !externalNodeMap.has(dep.sourceId)) {
99
+ externalNodeMap.set(dep.sourceId, resolveExternalNode(domain, dep.sourceId, dep.sourceKind));
100
+ }
101
+ if (!targetInternal && !externalNodeMap.has(dep.dependsOnId)) {
102
+ externalNodeMap.set(dep.dependsOnId, resolveExternalNode(domain, dep.dependsOnId, dep.dependsOnKind));
103
+ }
104
+
105
+ return {
106
+ id: dep.id,
107
+ sourceId: dep.sourceId,
108
+ sourceKind: dep.sourceKind,
109
+ dependsOnId: dep.dependsOnId,
110
+ dependsOnKind: dep.dependsOnKind,
111
+ internal,
112
+ };
113
+ });
114
+
115
+ const externalNodes = [...externalNodeMap.values()].sort((a, b) => a.id.localeCompare(b.id));
116
+
117
+ // Check for orphaned dependency references
118
+ for (const node of externalNodes) {
119
+ if (node.title === null) {
120
+ warnings.push({
121
+ code: "orphaned_external_node",
122
+ message: `External ${node.kind} ${node.id} referenced by a dependency but not found in the database`,
123
+ entityId: node.id,
124
+ });
125
+ }
126
+ }
127
+
128
+ const summary: ExportSummary = {
129
+ taskCount: tasks.length,
130
+ subtaskCount: allSubtasks.length,
131
+ dependencyCount: edges.length,
132
+ externalNodeCount: externalNodes.length,
133
+ warningCount: warnings.length,
134
+ taskStatuses: countStatuses(tasks),
135
+ subtaskStatuses: countStatuses(allSubtasks),
136
+ };
137
+
138
+ return {
139
+ schemaVersion: EXPORT_SCHEMA_VERSION,
140
+ exportedAt: Date.now(),
141
+ epic,
142
+ tasks,
143
+ subtasks: allSubtasks,
144
+ dependencies: edges,
145
+ externalNodes,
146
+ blockedBy: blockedByMap,
147
+ blocks: blocksMap,
148
+ warnings,
149
+ summary,
150
+ };
151
+ }
152
+
153
+ function resolveExternalNode(
154
+ domain: TrackerDomain,
155
+ id: string,
156
+ kind: "task" | "subtask",
157
+ ): ExportExternalNode {
158
+ if (kind === "task") {
159
+ const task = domain.getTask(id);
160
+ if (task) {
161
+ return { id, kind: "task", title: task.title, status: task.status, epicId: task.epicId };
162
+ }
163
+ } else {
164
+ const subtask = domain.getSubtask(id);
165
+ if (subtask) {
166
+ const task = domain.getTask(subtask.taskId);
167
+ return {
168
+ id,
169
+ kind: "subtask",
170
+ title: subtask.title,
171
+ status: subtask.status,
172
+ epicId: task?.epicId ?? null,
173
+ };
174
+ }
175
+ }
176
+
177
+ return { id, kind, title: null, status: null, epicId: null };
178
+ }
@@ -0,0 +1,48 @@
1
+ import { extname, isAbsolute, resolve } from "node:path";
2
+
3
+ const PLANS_DIRNAME = "plans";
4
+
5
+ function slugify(text: string): string {
6
+ return text
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9\s-]/g, "")
9
+ .replace(/\s+/g, "-")
10
+ .replace(/-+/g, "-")
11
+ .replace(/^-|-$/g, "")
12
+ .slice(0, 80);
13
+ }
14
+
15
+ function defaultFilename(epicTitle: string, epicId: string): string {
16
+ const slug = slugify(epicTitle) || epicId;
17
+ return `${slug}.md`;
18
+ }
19
+
20
+ function looksLikeFilePath(path: string): boolean {
21
+ return extname(path) !== "";
22
+ }
23
+
24
+ export function resolveExportPath(options: {
25
+ readonly customPath: string | undefined;
26
+ readonly epicId: string;
27
+ readonly epicTitle: string;
28
+ readonly worktreeRoot: string;
29
+ readonly cwd: string;
30
+ }): string {
31
+ const filename = defaultFilename(options.epicTitle, options.epicId);
32
+
33
+ if (!options.customPath) {
34
+ return resolve(options.worktreeRoot, PLANS_DIRNAME, filename);
35
+ }
36
+
37
+ const resolved = isAbsolute(options.customPath)
38
+ ? options.customPath
39
+ : resolve(options.cwd, options.customPath);
40
+
41
+ // If the path has a file extension, treat it as a file path.
42
+ // Otherwise treat it as a directory and place the default-named file inside.
43
+ if (looksLikeFilePath(resolved)) {
44
+ return resolved;
45
+ }
46
+
47
+ return resolve(resolved, filename);
48
+ }
@@ -0,0 +1,256 @@
1
+ import type { ExportBundle, ExportExternalNode, ExportStatusCounts, ExportWarning } from "./types";
2
+ import type { SubtaskRecord, TaskRecord } from "../domain/types";
3
+
4
+ export function renderMarkdown(bundle: ExportBundle): string {
5
+ const lines: string[] = [];
6
+
7
+ renderFrontmatter(lines, bundle);
8
+ renderTitle(lines, bundle);
9
+ renderSummaryTable(lines, bundle);
10
+ renderDescription(lines, bundle);
11
+ renderTaskIndex(lines, bundle);
12
+ renderTaskDetails(lines, bundle);
13
+ renderDependencies(lines, bundle);
14
+ renderExternalNodes(lines, bundle);
15
+ renderWarnings(lines, bundle);
16
+ renderFooter(lines, bundle);
17
+
18
+ return lines.join("\n") + "\n";
19
+ }
20
+
21
+ // --- Markdown escaping helpers ---
22
+
23
+ function escapeTableCell(text: string): string {
24
+ return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
25
+ }
26
+
27
+ function escapeHeading(text: string): string {
28
+ return text.replace(/\n/g, " ").replace(/#+\s*/g, "");
29
+ }
30
+
31
+ function escapeInlineText(text: string): string {
32
+ return text.replace(/([[\]`*_~])/g, "\\$1").replace(/\n/g, " ");
33
+ }
34
+
35
+ function escapeBlockText(text: string): string {
36
+ // Descriptions are rendered as block content; preserve newlines but
37
+ // ensure lines that start with Markdown structural characters are safe.
38
+ return text
39
+ .split("\n")
40
+ .map((line) => {
41
+ if (/^#{1,6}\s/.test(line)) return `\\${line}`;
42
+ if (/^(\s*[-*+]|\s*\d+\.)\s/.test(line)) return line;
43
+ if (/^\s*\|/.test(line)) return `\\${line}`;
44
+ return line;
45
+ })
46
+ .join("\n");
47
+ }
48
+
49
+ function formatDescriptionIndented(text: string, indent: string): string {
50
+ return text
51
+ .split("\n")
52
+ .map((line) => `${indent}${line}`)
53
+ .join("\n");
54
+ }
55
+
56
+ // --- Render sections ---
57
+
58
+ function renderFrontmatter(lines: string[], bundle: ExportBundle): void {
59
+ lines.push("---");
60
+ lines.push(`epic_id: ${bundle.epic.id}`);
61
+ lines.push(`schema_version: ${bundle.schemaVersion}`);
62
+ lines.push(`exported_at: ${new Date(bundle.exportedAt).toISOString()}`);
63
+ lines.push(`status: ${bundle.epic.status}`);
64
+ lines.push("---");
65
+ lines.push("");
66
+ }
67
+
68
+ function renderTitle(lines: string[], bundle: ExportBundle): void {
69
+ lines.push(`# ${escapeHeading(bundle.epic.title)}`);
70
+ lines.push("");
71
+ }
72
+
73
+ function renderSummaryTable(lines: string[], bundle: ExportBundle): void {
74
+ lines.push("## Summary");
75
+ lines.push("");
76
+ lines.push("| Metric | Count |");
77
+ lines.push("|--------|-------|");
78
+ lines.push(`| Tasks | ${bundle.summary.taskCount} |`);
79
+ lines.push(`| Subtasks | ${bundle.summary.subtaskCount} |`);
80
+ lines.push(`| Dependencies | ${bundle.summary.dependencyCount} |`);
81
+ lines.push(`| External nodes | ${bundle.summary.externalNodeCount} |`);
82
+ lines.push(`| Warnings | ${bundle.summary.warningCount} |`);
83
+ lines.push("");
84
+
85
+ if (bundle.summary.taskCount > 0) {
86
+ lines.push("### Task status breakdown");
87
+ lines.push("");
88
+ renderStatusTable(lines, bundle.summary.taskStatuses);
89
+ lines.push("");
90
+ }
91
+
92
+ if (bundle.summary.subtaskCount > 0) {
93
+ lines.push("### Subtask status breakdown");
94
+ lines.push("");
95
+ renderStatusTable(lines, bundle.summary.subtaskStatuses);
96
+ lines.push("");
97
+ }
98
+ }
99
+
100
+ function renderStatusTable(lines: string[], counts: ExportStatusCounts): void {
101
+ lines.push("| Status | Count |");
102
+ lines.push("|--------|-------|");
103
+ if (counts.todo > 0) lines.push(`| todo | ${counts.todo} |`);
104
+ if (counts.inProgress > 0) lines.push(`| in_progress | ${counts.inProgress} |`);
105
+ if (counts.done > 0) lines.push(`| done | ${counts.done} |`);
106
+ if (counts.blocked > 0) lines.push(`| blocked | ${counts.blocked} |`);
107
+ if (counts.other > 0) lines.push(`| other | ${counts.other} |`);
108
+ }
109
+
110
+ function renderDescription(lines: string[], bundle: ExportBundle): void {
111
+ if (!bundle.epic.description) return;
112
+
113
+ lines.push("## Description");
114
+ lines.push("");
115
+ lines.push(escapeBlockText(bundle.epic.description));
116
+ lines.push("");
117
+ }
118
+
119
+ function renderTaskIndex(lines: string[], bundle: ExportBundle): void {
120
+ if (bundle.tasks.length === 0) return;
121
+
122
+ lines.push("## Task index");
123
+ lines.push("");
124
+ lines.push("| # | Title | Status | Subtasks |");
125
+ lines.push("|---|-------|--------|----------|");
126
+
127
+ for (let i = 0; i < bundle.tasks.length; i++) {
128
+ const task = bundle.tasks[i];
129
+ const subtaskCount = bundle.subtasks.filter((s) => s.taskId === task.id).length;
130
+ const anchor = taskAnchor(task);
131
+ lines.push(`| ${i + 1} | [${escapeTableCell(escapeInlineText(task.title))}](#${anchor}) | ${task.status} | ${subtaskCount} |`);
132
+ }
133
+ lines.push("");
134
+ }
135
+
136
+ function renderTaskDetails(lines: string[], bundle: ExportBundle): void {
137
+ if (bundle.tasks.length === 0) return;
138
+
139
+ lines.push("## Tasks");
140
+ lines.push("");
141
+
142
+ for (const task of bundle.tasks) {
143
+ renderSingleTask(lines, task, bundle);
144
+ }
145
+ }
146
+
147
+ function renderSingleTask(lines: string[], task: TaskRecord, bundle: ExportBundle): void {
148
+ lines.push(`### ${escapeHeading(task.title)}`);
149
+ lines.push("");
150
+ lines.push(`**ID:** \`${task.id}\` `);
151
+ lines.push(`**Status:** ${task.status} `);
152
+ if (task.owner) {
153
+ lines.push(`**Owner:** ${escapeInlineText(task.owner)} `);
154
+ }
155
+ lines.push("");
156
+
157
+ if (task.description) {
158
+ lines.push(escapeBlockText(task.description));
159
+ lines.push("");
160
+ }
161
+
162
+ const blockedBy = bundle.blockedBy.get(task.id) ?? [];
163
+ if (blockedBy.length > 0) {
164
+ lines.push("**Blocked by:**");
165
+ for (const depId of blockedBy) {
166
+ lines.push(`- \`${depId}\``);
167
+ }
168
+ lines.push("");
169
+ }
170
+
171
+ const blocks = bundle.blocks.get(task.id) ?? [];
172
+ if (blocks.length > 0) {
173
+ lines.push("**Blocks:**");
174
+ for (const depId of blocks) {
175
+ lines.push(`- \`${depId}\``);
176
+ }
177
+ lines.push("");
178
+ }
179
+
180
+ const subtasks = bundle.subtasks.filter((s) => s.taskId === task.id);
181
+ if (subtasks.length > 0) {
182
+ lines.push("#### Subtasks");
183
+ lines.push("");
184
+ for (const subtask of subtasks) {
185
+ renderSingleSubtask(lines, subtask, bundle);
186
+ }
187
+ }
188
+ }
189
+
190
+ function renderSingleSubtask(lines: string[], subtask: SubtaskRecord, bundle: ExportBundle): void {
191
+ const statusIcon = subtask.status === "done" ? "x" : " ";
192
+ lines.push(`- [${statusIcon}] **${escapeInlineText(subtask.title)}** — \`${subtask.id}\` (${subtask.status})`);
193
+
194
+ if (subtask.description) {
195
+ lines.push(formatDescriptionIndented(escapeBlockText(subtask.description), " "));
196
+ }
197
+
198
+ const blockedBy = bundle.blockedBy.get(subtask.id) ?? [];
199
+ if (blockedBy.length > 0) {
200
+ lines.push(` Blocked by: ${blockedBy.map((id) => `\`${id}\``).join(", ")}`);
201
+ }
202
+ }
203
+
204
+ function renderDependencies(lines: string[], bundle: ExportBundle): void {
205
+ if (bundle.dependencies.length === 0) return;
206
+
207
+ lines.push("");
208
+ lines.push("## Dependencies");
209
+ lines.push("");
210
+ lines.push("| Source | Depends on | Type |");
211
+ lines.push("|--------|------------|------|");
212
+
213
+ for (const dep of bundle.dependencies) {
214
+ const type = dep.internal ? "internal" : "external";
215
+ lines.push(`| \`${dep.sourceId}\` (${dep.sourceKind}) | \`${dep.dependsOnId}\` (${dep.dependsOnKind}) | ${type} |`);
216
+ }
217
+ lines.push("");
218
+ }
219
+
220
+ function renderExternalNodes(lines: string[], bundle: ExportBundle): void {
221
+ if (bundle.externalNodes.length === 0) return;
222
+
223
+ lines.push("## External nodes");
224
+ lines.push("");
225
+ lines.push("These nodes belong to other epics but are referenced by dependencies in this epic.");
226
+ lines.push("");
227
+ lines.push("| ID | Kind | Title | Status | Epic ID |");
228
+ lines.push("|----|------|-------|--------|---------|");
229
+
230
+ for (const node of bundle.externalNodes) {
231
+ const title = node.title !== null ? escapeTableCell(node.title) : "—";
232
+ lines.push(`| \`${node.id}\` | ${node.kind} | ${title} | ${node.status ?? "—"} | ${node.epicId ?? "—"} |`);
233
+ }
234
+ lines.push("");
235
+ }
236
+
237
+ function renderWarnings(lines: string[], bundle: ExportBundle): void {
238
+ if (bundle.warnings.length === 0) return;
239
+
240
+ lines.push("## Warnings");
241
+ lines.push("");
242
+ for (const warning of bundle.warnings) {
243
+ lines.push(`- **${escapeInlineText(warning.code)}**: ${escapeInlineText(warning.message)}`);
244
+ }
245
+ lines.push("");
246
+ }
247
+
248
+ function renderFooter(lines: string[], bundle: ExportBundle): void {
249
+ lines.push("---");
250
+ lines.push("");
251
+ lines.push(`*Exported from Trekoon on ${new Date(bundle.exportedAt).toISOString()}. This is a snapshot — the database is the source of truth.*`);
252
+ }
253
+
254
+ function taskAnchor(task: TaskRecord): string {
255
+ return `task-${task.id}`;
256
+ }
@@ -0,0 +1,61 @@
1
+ import type { DependencyRecord, EpicRecord, SubtaskRecord, TaskRecord } from "../domain/types";
2
+
3
+ export const EXPORT_SCHEMA_VERSION = 1;
4
+
5
+ export type ExportNodeKind = "task" | "subtask";
6
+
7
+ export interface ExportDependencyEdge {
8
+ readonly id: string;
9
+ readonly sourceId: string;
10
+ readonly sourceKind: ExportNodeKind;
11
+ readonly dependsOnId: string;
12
+ readonly dependsOnKind: ExportNodeKind;
13
+ readonly internal: boolean;
14
+ }
15
+
16
+ export interface ExportExternalNode {
17
+ readonly id: string;
18
+ readonly kind: ExportNodeKind;
19
+ readonly title: string | null;
20
+ readonly status: string | null;
21
+ readonly epicId: string | null;
22
+ }
23
+
24
+ export interface ExportWarning {
25
+ readonly code: string;
26
+ readonly message: string;
27
+ readonly entityId?: string;
28
+ }
29
+
30
+ export interface ExportStatusCounts {
31
+ readonly total: number;
32
+ readonly todo: number;
33
+ readonly inProgress: number;
34
+ readonly done: number;
35
+ readonly blocked: number;
36
+ readonly other: number;
37
+ }
38
+
39
+ export interface ExportSummary {
40
+ readonly taskCount: number;
41
+ readonly subtaskCount: number;
42
+ readonly dependencyCount: number;
43
+ readonly externalNodeCount: number;
44
+ readonly warningCount: number;
45
+ readonly taskStatuses: ExportStatusCounts;
46
+ readonly subtaskStatuses: ExportStatusCounts;
47
+ }
48
+
49
+ export interface ExportBundle {
50
+ readonly schemaVersion: number;
51
+ readonly exportedAt: number;
52
+ readonly epic: EpicRecord;
53
+ readonly tasks: readonly TaskRecord[];
54
+ readonly subtasks: readonly SubtaskRecord[];
55
+ readonly dependencies: readonly ExportDependencyEdge[];
56
+ readonly externalNodes: readonly ExportExternalNode[];
57
+ readonly blockedBy: ReadonlyMap<string, readonly string[]>;
58
+ readonly blocks: ReadonlyMap<string, readonly string[]>;
59
+ readonly warnings: readonly ExportWarning[];
60
+ readonly summary: ExportSummary;
61
+ }
@@ -0,0 +1,97 @@
1
+ import { mkdirSync, openSync, renameSync, unlinkSync, writeSync, closeSync, constants } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ export interface WriteResult {
6
+ readonly path: string;
7
+ readonly overwritten: boolean;
8
+ }
9
+
10
+ export function atomicWrite(options: {
11
+ readonly path: string;
12
+ readonly content: string;
13
+ readonly overwrite: boolean;
14
+ }): WriteResult {
15
+ const dir = dirname(options.path);
16
+ mkdirSync(dir, { recursive: true });
17
+
18
+ if (options.overwrite) {
19
+ return writeViaTempRename(options.path, options.content);
20
+ }
21
+
22
+ return writeExclusive(options.path, options.content);
23
+ }
24
+
25
+ function writeViaTempRename(targetPath: string, content: string): WriteResult {
26
+ const dir = dirname(targetPath);
27
+ const tempPath = resolve(dir, `.export-${randomUUID()}.tmp`);
28
+ let overwritten = false;
29
+
30
+ try {
31
+ // Probe whether the target already exists by attempting an exclusive open.
32
+ // If it succeeds, the file didn't exist, so we close and remove that probe
33
+ // since we'll write via temp+rename anyway for atomicity.
34
+ try {
35
+ const probeFd = openSync(targetPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
36
+ closeSync(probeFd);
37
+ unlinkSync(targetPath);
38
+ overwritten = false;
39
+ } catch {
40
+ overwritten = true;
41
+ }
42
+
43
+ const fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC);
44
+ try {
45
+ writeSync(fd, content);
46
+ } finally {
47
+ closeSync(fd);
48
+ }
49
+ renameSync(tempPath, targetPath);
50
+ } catch (error) {
51
+ try { unlinkSync(tempPath); } catch { /* best-effort cleanup */ }
52
+ throw error;
53
+ }
54
+
55
+ return { path: targetPath, overwritten };
56
+ }
57
+
58
+ function writeExclusive(targetPath: string, content: string): WriteResult {
59
+ // O_CREAT | O_EXCL is atomic: the kernel fails if the file already exists.
60
+ let fd: number;
61
+ try {
62
+ fd = openSync(targetPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
63
+ } catch (error: unknown) {
64
+ if (isFileExistsError(error)) {
65
+ throw new ExportWriteError(
66
+ `File already exists: ${targetPath}. Use --overwrite to resave.`,
67
+ "file_exists",
68
+ );
69
+ }
70
+ throw error;
71
+ }
72
+
73
+ try {
74
+ writeSync(fd, content);
75
+ } finally {
76
+ closeSync(fd);
77
+ }
78
+
79
+ return { path: targetPath, overwritten: false };
80
+ }
81
+
82
+ function isFileExistsError(error: unknown): boolean {
83
+ if (typeof error === "object" && error !== null && "code" in error) {
84
+ return (error as { code: string }).code === "EEXIST";
85
+ }
86
+ return false;
87
+ }
88
+
89
+ export class ExportWriteError extends Error {
90
+ readonly code: string;
91
+
92
+ constructor(message: string, code: string) {
93
+ super(message);
94
+ this.name = "ExportWriteError";
95
+ this.code = code;
96
+ }
97
+ }