trekoon 0.3.7 → 0.3.9

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.
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
@@ -2,6 +2,27 @@
2
2
 
3
3
  Shortest path from zero to a working Trekoon workflow.
4
4
 
5
+ ## Recommended human workflow
6
+
7
+ If you are driving Trekoon with an AI agent, the usual path is:
8
+
9
+ ```bash
10
+ trekoon plan <goal>
11
+ trekoon <epic-id>
12
+ trekoon <epic-id> execute
13
+ ```
14
+
15
+ - Use `plan` after you already have enough context from discussion,
16
+ brainstorming, or research. Trekoon should turn that context into an
17
+ execution-ready epic.
18
+ - Use `trekoon <epic-id>` to inspect the created epic, next ready work, and any
19
+ blockers before starting execution.
20
+ - Use `execute` when you want the agent to keep working through the epic until
21
+ it is done, all remaining work is blocked, or it needs your input.
22
+
23
+ The rest of this page is mostly the lower-level command surface that agents and
24
+ power users rely on.
25
+
5
26
  ## How storage works
6
27
 
7
28
  Trekoon keeps one SQLite database per repository at `.trekoon/trekoon.db`. In
@@ -113,6 +134,20 @@ Cascades atomically through all descendants. If any descendant has an unresolved
113
134
  external dependency, the whole update fails with no partial writes. Works with
114
135
  `--status done` and `--status todo` only.
115
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
+
116
151
  ## Check progress
117
152
 
118
153
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "AI-first local issue tracker CLI.",
5
5
  "keywords": [
6
6
  "ai",
@@ -13,7 +13,6 @@ import { createConfirmDialog } from "./components/ConfirmDialog.js";
13
13
  import { createEpicsOverview } from "./components/EpicsOverview.js";
14
14
  import { panelClasses, renderIcon, sectionLabelClasses, escapeHtml } from "./components/helpers.js";
15
15
 
16
- const SESSION_TOKEN_STORAGE_KEY = "trekoon-board-session-token";
17
16
  const SEARCH_FOCUS_KEYS = new Set(["/", "s"]);
18
17
  const FOCUSABLE_SELECTOR = [
19
18
  "a[href]",
@@ -33,30 +32,15 @@ const FOCUSABLE_SELECTOR = [
33
32
  // Session token management
34
33
  // ---------------------------------------------------------------------------
35
34
 
36
- function readSessionTokenFromStorage() {
37
- try {
38
- return (sessionStorage.getItem(SESSION_TOKEN_STORAGE_KEY) || "").trim();
39
- } catch {
40
- return "";
41
- }
42
- }
43
-
44
- function persistSessionToken(token) {
45
- try {
46
- sessionStorage.setItem(SESSION_TOKEN_STORAGE_KEY, token);
47
- return true;
48
- } catch {
49
- return false;
50
- }
51
- }
52
-
53
35
  function resolveRuntimeSession() {
54
36
  const url = new URL(window.location.href);
55
37
  const queryToken = (url.searchParams.get("token") || "").trim();
56
38
  if (queryToken.length > 0) {
57
- return { token: queryToken, shouldScrubAddressBar: persistSessionToken(queryToken) };
39
+ return { token: queryToken, shouldScrubAddressBar: true };
58
40
  }
59
- return { token: readSessionTokenFromStorage(), shouldScrubAddressBar: false };
41
+ const bootstrap = readJsonScript("trekoon-board-bootstrap") ?? {};
42
+ const bootstrapToken = typeof bootstrap?.token === "string" ? bootstrap.token.trim() : "";
43
+ return { token: bootstrapToken, shouldScrubAddressBar: false };
60
44
  }
61
45
 
62
46
  function scrubTokenFromAddressBar() {
@@ -149,11 +133,10 @@ export async function bootLegacyBoard(options = {}) {
149
133
  if (runtimeSession.shouldScrubAddressBar) scrubTokenFromAddressBar();
150
134
 
151
135
  // Fetch snapshot
152
- let snapshotPayload = readJsonScript("trekoon-board-snapshot") ?? {};
153
- if (runtimeSession.token.length > 0) {
154
- const headers = new Headers();
155
- headers.set("authorization", `Bearer ${runtimeSession.token}`);
156
- const response = await fetch("/api/snapshot", { headers });
136
+ const bootstrap = readJsonScript("trekoon-board-bootstrap") ?? {};
137
+ let snapshotPayload = bootstrap?.snapshot ?? readJsonScript("trekoon-board-snapshot") ?? {};
138
+ if ((!snapshotPayload || typeof snapshotPayload !== "object") && runtimeSession.token.length > 0) {
139
+ const response = await fetch("/api/snapshot");
157
140
  const payload = await response.json();
158
141
  if (!payload?.ok) throw new Error(payload?.error?.message || "Board request failed");
159
142
  snapshotPayload = payload?.data?.snapshot ?? {};
@@ -124,13 +124,12 @@ export function createMutationQueue(model, rerender) {
124
124
  model.store.notice = null;
125
125
  }
126
126
 
127
- // Apply optimistic update
128
- if (typeof mutation.optimistic === "function") {
129
- model.store.snapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
130
- rerender();
131
- }
132
-
133
127
  try {
128
+ if (typeof mutation.optimistic === "function") {
129
+ model.store.snapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
130
+ rerender();
131
+ }
132
+
134
133
  const data = await mutation.request();
135
134
 
136
135
  if (data?.snapshot) {
@@ -27,10 +27,10 @@ function normalizeArray(value) {
27
27
 
28
28
  /**
29
29
  * @param {{ id?: string }} record
30
- * @returns {string}
30
+ * @returns {string|null}
31
31
  */
32
32
  function getId(record) {
33
- return typeof record?.id === "string" && record.id.length > 0 ? record.id : crypto.randomUUID();
33
+ return typeof record?.id === "string" && record.id.length > 0 ? record.id : null;
34
34
  }
35
35
 
36
36
  function normalizeTimestamp(value, fallback) {
@@ -42,6 +42,10 @@ function normalizeText(value, fallback = "") {
42
42
  return String(value ?? fallback).replace(/\\n/g, "\n");
43
43
  }
44
44
 
45
+ function normalizeOwner(value) {
46
+ return typeof value === "string" ? value : null;
47
+ }
48
+
45
49
  /**
46
50
  * @param {any[]} tasks
47
51
  * @returns {Record<string, number>}
@@ -68,15 +72,21 @@ export function normalizeSnapshot(rawSnapshot) {
68
72
  const taskIndex = new Map();
69
73
  const subtaskIndex = new Map();
70
74
 
71
- const tasks = rawTasks.map((task) => {
75
+ const tasks = rawTasks.flatMap((task) => {
76
+ const taskId = getId(task);
77
+ if (!taskId) {
78
+ return [];
79
+ }
80
+
72
81
  const createdAt = normalizeTimestamp(task.createdAt, Date.now());
73
82
  const normalizedTask = {
74
- id: getId(task),
83
+ id: taskId,
75
84
  kind: "task",
76
85
  epicId: task.epicId ?? task.epic?.id ?? null,
77
86
  title: normalizeText(task.title, "Untitled task"),
78
87
  description: normalizeText(task.description),
79
88
  status: normalizeStatus(task.status),
89
+ owner: normalizeOwner(task.owner),
80
90
  createdAt,
81
91
  updatedAt: normalizeTimestamp(task.updatedAt, createdAt),
82
92
  blockedBy: [],
@@ -88,18 +98,24 @@ export function normalizeSnapshot(rawSnapshot) {
88
98
  };
89
99
 
90
100
  taskIndex.set(normalizedTask.id, normalizedTask);
91
- return normalizedTask;
101
+ return [normalizedTask];
92
102
  });
93
103
 
94
- const subtasks = rawSubtasks.map((subtask) => {
104
+ const subtasks = rawSubtasks.flatMap((subtask) => {
105
+ const subtaskId = getId(subtask);
106
+ if (!subtaskId) {
107
+ return [];
108
+ }
109
+
95
110
  const createdAt = normalizeTimestamp(subtask.createdAt, Date.now());
96
111
  const normalizedSubtask = {
97
- id: getId(subtask),
112
+ id: subtaskId,
98
113
  kind: "subtask",
99
114
  taskId: subtask.taskId ?? subtask.task?.id ?? null,
100
115
  title: normalizeText(subtask.title, "Untitled subtask"),
101
116
  description: normalizeText(subtask.description),
102
117
  status: normalizeStatus(subtask.status),
118
+ owner: normalizeOwner(subtask.owner),
103
119
  createdAt,
104
120
  updatedAt: normalizeTimestamp(subtask.updatedAt, createdAt),
105
121
  blockedBy: [],
@@ -110,7 +126,7 @@ export function normalizeSnapshot(rawSnapshot) {
110
126
  };
111
127
 
112
128
  subtaskIndex.set(normalizedSubtask.id, normalizedSubtask);
113
- return normalizedSubtask;
129
+ return [normalizedSubtask];
114
130
  });
115
131
 
116
132
  for (const subtask of subtasks) {
@@ -120,13 +136,22 @@ export function normalizeSnapshot(rawSnapshot) {
120
136
  }
121
137
  }
122
138
 
123
- const dependencies = rawDependencies.map((dependency) => ({
124
- id: getId(dependency),
125
- sourceId: String(dependency.sourceId ?? ""),
126
- sourceKind: dependency.sourceKind === "subtask" ? "subtask" : "task",
127
- dependsOnId: String(dependency.dependsOnId ?? ""),
128
- dependsOnKind: dependency.dependsOnKind === "subtask" ? "subtask" : "task",
129
- }));
139
+ const dependencies = rawDependencies.flatMap((dependency) => {
140
+ const dependencyId = getId(dependency);
141
+ const sourceId = typeof dependency?.sourceId === "string" && dependency.sourceId.length > 0 ? dependency.sourceId : null;
142
+ const dependsOnId = typeof dependency?.dependsOnId === "string" && dependency.dependsOnId.length > 0 ? dependency.dependsOnId : null;
143
+ if (!dependencyId || !sourceId || !dependsOnId) {
144
+ return [];
145
+ }
146
+
147
+ return [{
148
+ id: dependencyId,
149
+ sourceId,
150
+ sourceKind: dependency.sourceKind === "subtask" ? "subtask" : "task",
151
+ dependsOnId,
152
+ dependsOnKind: dependency.dependsOnKind === "subtask" ? "subtask" : "task",
153
+ }];
154
+ });
130
155
 
131
156
  const lookupNode = (kind, id) => {
132
157
  if (kind === "subtask") {
@@ -148,8 +173,12 @@ export function normalizeSnapshot(rawSnapshot) {
148
173
  }
149
174
  }
150
175
 
151
- const epics = rawEpics.map((epic) => {
176
+ const epics = rawEpics.flatMap((epic) => {
152
177
  const epicId = getId(epic);
178
+ if (!epicId) {
179
+ return [];
180
+ }
181
+
153
182
  const epicTasks = tasks.filter((task) => task.epicId === epicId);
154
183
  const createdAt = normalizeTimestamp(epic.createdAt, Date.now());
155
184
  const normalizedEpic = {
@@ -165,7 +194,7 @@ export function normalizeSnapshot(rawSnapshot) {
165
194
  };
166
195
 
167
196
  normalizedEpic.searchText = [normalizedEpic.title, normalizedEpic.description, ...epicTasks.map((task) => task.title)].join(" ").toLowerCase();
168
- return normalizedEpic;
197
+ return [normalizedEpic];
169
198
  });
170
199
 
171
200
  for (const subtask of subtasks) {
@@ -214,6 +243,10 @@ function mergeRecordsById(existingRecords, incomingRecords, deletedIds = []) {
214
243
  const indexById = new Map(nextRecords.map((record, index) => [record.id, index]));
215
244
 
216
245
  for (const record of incomingRecords) {
246
+ if (typeof record?.id !== "string" || record.id.length === 0) {
247
+ continue;
248
+ }
249
+
217
250
  const existingIndex = indexById.get(record.id);
218
251
  if (existingIndex === undefined) {
219
252
  indexById.set(record.id, nextRecords.length);
@@ -4,7 +4,7 @@ import { safeErrorMessage } from "../commands/error-utils";
4
4
  import { MutationService } from "../domain/mutation-service";
5
5
  import { TrackerDomain } from "../domain/tracker-domain";
6
6
  import { DomainError } from "../domain/types";
7
- import { buildBoardSnapshot } from "./snapshot";
7
+ import { buildBoardSnapshot, buildBoardSnapshotDelta } from "./snapshot";
8
8
 
9
9
  interface SnapshotDeltaSelection {
10
10
  readonly epicIds?: readonly string[];
@@ -63,6 +63,25 @@ function jsonResponse(status: number, data: unknown): Response {
63
63
  });
64
64
  }
65
65
 
66
+ function readCookieToken(request: Request): string | null {
67
+ const rawCookie = request.headers.get("cookie");
68
+ if (!rawCookie) {
69
+ return null;
70
+ }
71
+
72
+ for (const part of rawCookie.split(";")) {
73
+ const [name, ...valueParts] = part.split("=");
74
+ if (name?.trim() !== "trekoon_board_session") {
75
+ continue;
76
+ }
77
+
78
+ const value = valueParts.join("=").trim();
79
+ return value.length > 0 ? decodeURIComponent(value) : null;
80
+ }
81
+
82
+ return null;
83
+ }
84
+
66
85
  function extractToken(request: Request, url: URL): string | null {
67
86
  const authorization: string | null = request.headers.get("authorization");
68
87
  if (authorization?.startsWith("Bearer ")) {
@@ -79,7 +98,7 @@ function extractToken(request: Request, url: URL): string | null {
79
98
  return queryToken.trim();
80
99
  }
81
100
 
82
- return null;
101
+ return readCookieToken(request);
83
102
  }
84
103
 
85
104
  function isSqliteBusyMessage(message: string): boolean {
@@ -147,23 +166,7 @@ function buildMutationResponse(_domain: TrackerDomain, data: Record<string, unkn
147
166
  });
148
167
  }
149
168
 
150
- function buildSnapshotDelta(domain: TrackerDomain, selection: SnapshotDeltaSelection): Record<string, unknown> {
151
- const snapshot = buildBoardSnapshot(domain);
152
- const epicIdSet = new Set(selection.epicIds ?? []);
153
- const taskIdSet = new Set(selection.taskIds ?? []);
154
- const subtaskIdSet = new Set(selection.subtaskIds ?? []);
155
- const dependencyIdSet = new Set(selection.dependencyIds ?? []);
156
-
157
- return {
158
- generatedAt: snapshot.generatedAt,
159
- epics: snapshot.epics.filter((epic) => epicIdSet.has(epic.id)),
160
- tasks: snapshot.tasks.filter((task) => taskIdSet.has(task.id)),
161
- subtasks: snapshot.subtasks.filter((subtask) => subtaskIdSet.has(subtask.id)),
162
- dependencies: snapshot.dependencies.filter((dependency) => dependencyIdSet.has(dependency.id)),
163
- deletedSubtaskIds: [...(selection.deletedSubtaskIds ?? [])],
164
- deletedDependencyIds: [...(selection.deletedDependencyIds ?? [])],
165
- };
166
- }
169
+ const buildSnapshotDelta = buildBoardSnapshotDelta;
167
170
 
168
171
  function buildMutationDeltaResponse(
169
172
  domain: TrackerDomain,
@@ -3,9 +3,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, extname, resolve } from "node:path";
4
4
 
5
5
  import { createBoardApiHandler } from "./routes";
6
+ import { buildBoardSnapshot } from "./snapshot";
6
7
 
7
8
  import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
8
9
  import { resolveStoragePaths } from "../storage/path";
10
+ import { TrackerDomain } from "../domain/tracker-domain";
9
11
 
10
12
  const CONTENT_TYPES: Record<string, string> = {
11
13
  ".css": "text/css; charset=utf-8",
@@ -87,6 +89,37 @@ function isUnavailablePortError(error: unknown): boolean {
87
89
  return /^(EADDRINUSE|EACCES)$/i.test(errorCode) || /(EADDRINUSE|EACCES|address already in use|permission denied)/i.test(error.message);
88
90
  }
89
91
 
92
+ function buildBoardSessionCookie(token: string): string {
93
+ return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict; HttpOnly`;
94
+ }
95
+
96
+ function serializeInlineJson(value: unknown): string {
97
+ return JSON.stringify(value)
98
+ .replace(/</g, "\\u003c")
99
+ .replace(/>/g, "\\u003e")
100
+ .replace(/&/g, "\\u0026")
101
+ .replace(/\u2028/g, "\\u2028")
102
+ .replace(/\u2029/g, "\\u2029");
103
+ }
104
+
105
+ function buildBoardBootstrapPayload(database: TrekoonDatabase, token: string): string {
106
+ const domain = new TrackerDomain(database.db);
107
+ return serializeInlineJson({
108
+ token,
109
+ snapshot: buildBoardSnapshot(domain),
110
+ });
111
+ }
112
+
113
+ function injectBoardBootstrap(html: string, bootstrapJson: string): string {
114
+ const bootstrapTag = `<script id="trekoon-board-bootstrap" type="application/json">${bootstrapJson}</script>`;
115
+ const closingBodyIndex = html.lastIndexOf("</body>");
116
+ if (closingBodyIndex === -1) {
117
+ return `${html}${bootstrapTag}`;
118
+ }
119
+
120
+ return `${html.slice(0, closingBodyIndex)}${bootstrapTag}\n${html.slice(closingBodyIndex)}`;
121
+ }
122
+
90
123
  export function startBoardServer(options: StartBoardServerOptions = {}): BoardServerInfo {
91
124
  const cwd: string = options.cwd ?? process.cwd();
92
125
  const database: TrekoonDatabase = openTrekoonDatabase(cwd);
@@ -110,6 +143,13 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
110
143
  return apiHandler(request);
111
144
  }
112
145
 
146
+ const responseHeaders: Record<string, string> = {
147
+ "cache-control": "no-store",
148
+ };
149
+ if ((url.searchParams.get("token") ?? "") === token) {
150
+ responseHeaders["set-cookie"] = buildBoardSessionCookie(token);
151
+ }
152
+
113
153
  const assetPath = readAssetPath(boardRoot, url.pathname === "/" ? "/index.html" : url.pathname);
114
154
  if (assetPath === null) {
115
155
  const fallbackPath = readAssetPath(boardRoot, "/index.html");
@@ -117,9 +157,21 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
117
157
  return new Response("Board assets are not installed", { status: 500 });
118
158
  }
119
159
 
120
- return new Response(readFileSync(fallbackPath), {
160
+ const html = injectBoardBootstrap(readFileSync(fallbackPath, "utf8"), buildBoardBootstrapPayload(database, token));
161
+
162
+ return new Response(html, {
163
+ headers: {
164
+ ...responseHeaders,
165
+ "content-type": "text/html; charset=utf-8",
166
+ },
167
+ });
168
+ }
169
+
170
+ if (assetPath.endsWith("/index.html")) {
171
+ const html = injectBoardBootstrap(readFileSync(assetPath, "utf8"), buildBoardBootstrapPayload(database, token));
172
+ return new Response(html, {
121
173
  headers: {
122
- "cache-control": "no-store",
174
+ ...responseHeaders,
123
175
  "content-type": "text/html; charset=utf-8",
124
176
  },
125
177
  });
@@ -127,7 +179,7 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
127
179
 
128
180
  return new Response(readFileSync(assetPath), {
129
181
  headers: {
130
- "cache-control": "no-store",
182
+ ...responseHeaders,
131
183
  "content-type": guessContentType(assetPath),
132
184
  },
133
185
  });
@@ -169,11 +221,12 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
169
221
 
170
222
  const origin: string = `http://127.0.0.1:${port}`;
171
223
  const url: string = `${origin}/?token=${encodeURIComponent(token)}`;
224
+ const fallbackUrl: string = origin;
172
225
 
173
226
  return {
174
227
  origin,
175
228
  url,
176
- fallbackUrl: url,
229
+ fallbackUrl,
177
230
  token,
178
231
  hostname: "127.0.0.1",
179
232
  port,