trekoon 0.4.2 → 0.4.4

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.
Files changed (40) hide show
  1. package/.agents/skills/trekoon/SKILL.md +97 -208
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +87 -149
  3. package/.agents/skills/trekoon/reference/execution.md +170 -380
  4. package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
  5. package/.agents/skills/trekoon/reference/planning.md +193 -330
  6. package/.agents/skills/trekoon/reference/sync.md +56 -103
  7. package/README.md +29 -10
  8. package/docs/ai-agents.md +48 -4
  9. package/docs/commands.md +34 -25
  10. package/docs/machine-contracts.md +1 -1
  11. package/docs/quickstart.md +9 -9
  12. package/package.json +2 -2
  13. package/src/board/asset-root.ts +73 -0
  14. package/src/board/assets/app.js +5 -3
  15. package/src/board/assets/components/Component.js +6 -8
  16. package/src/board/assets/state/actions.js +3 -0
  17. package/src/board/assets/state/api.js +48 -34
  18. package/src/board/assets/state/store.js +3 -0
  19. package/src/board/event-bus.ts +15 -0
  20. package/src/board/routes.ts +94 -83
  21. package/src/board/server.ts +35 -8
  22. package/src/board/snapshot.ts +6 -0
  23. package/src/board/types.ts +2 -34
  24. package/src/board/wal-watcher.ts +170 -28
  25. package/src/commands/board.ts +20 -42
  26. package/src/commands/help.ts +11 -12
  27. package/src/commands/init.ts +0 -29
  28. package/src/commands/quickstart.ts +1 -1
  29. package/src/commands/skills.ts +17 -5
  30. package/src/domain/mutation-service.ts +61 -42
  31. package/src/domain/tracker-domain.ts +20 -16
  32. package/src/domain/types.ts +3 -0
  33. package/src/export/render-markdown.ts +1 -2
  34. package/src/runtime/daemon.ts +110 -49
  35. package/src/runtime/version.ts +10 -2
  36. package/src/storage/database.ts +9 -2
  37. package/src/storage/migrations.ts +19 -2
  38. package/src/storage/path.ts +0 -36
  39. package/src/sync/service.ts +47 -27
  40. package/src/board/install.ts +0 -196
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, extname, resolve } from "node:path";
4
4
 
5
+ import { resolveBoardAssetRoot, type BoardAssetRoot } from "./asset-root";
5
6
  import { createBoardEventBus, type BoardEventBus } from "./event-bus";
6
7
  import { createBoardApiHandler } from "./routes";
7
8
  import { startWalWatcher, type WalWatcher } from "./wal-watcher";
@@ -38,6 +39,14 @@ export interface BoardServerInfo {
38
39
  export interface StartBoardServerOptions {
39
40
  readonly cwd?: string;
40
41
  readonly token?: string;
42
+ /**
43
+ * Optional override for the directory the board server reads static assets
44
+ * from. When unset, the server resolves the asset root via
45
+ * `resolveBoardAssetRoot()`, which honours `TREKOON_BOARD_ASSET_ROOT` and
46
+ * falls back to the assets shipped alongside the running Trekoon package.
47
+ * Database, API, SSE, and WAL watcher paths continue to use `cwd`.
48
+ */
49
+ readonly assetRootOverride?: string;
41
50
  }
42
51
 
43
52
  function guessContentType(pathname: string): string {
@@ -90,7 +99,7 @@ function isUnavailablePortError(error: unknown): boolean {
90
99
  }
91
100
 
92
101
  function buildBoardSessionCookie(token: string): string {
93
- return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict; HttpOnly`;
102
+ return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; Max-Age=86400; SameSite=Strict; HttpOnly`;
94
103
  }
95
104
 
96
105
  function readBoardSessionCookie(request: Request): string | null {
@@ -122,6 +131,13 @@ function isAuthenticatedBoardRequest(request: Request, url: URL, token: string):
122
131
  return cookieToken !== null && cookieToken === token;
123
132
  }
124
133
 
134
+ function buildTokenStrippedLocation(url: URL): string {
135
+ const redirectUrl = new URL(url);
136
+ redirectUrl.searchParams.delete("token");
137
+ const pathname = `/${redirectUrl.pathname.replace(/^\/+/u, "")}`;
138
+ return `${pathname}${redirectUrl.search}${redirectUrl.hash}`;
139
+ }
140
+
125
141
  function serializeInlineJson(value: unknown): string {
126
142
  return JSON.stringify(value)
127
143
  .replace(/</g, "\\u003c")
@@ -152,11 +168,22 @@ function injectBoardBootstrap(html: string, bootstrapJson: string): string {
152
168
 
153
169
  export function startBoardServer(options: StartBoardServerOptions = {}): BoardServerInfo {
154
170
  const cwd: string = options.cwd ?? process.cwd();
171
+ // Resolve the asset root BEFORE generating a token or opening the database.
172
+ // If the installed assets are missing, `resolveBoardAssetRoot` raises a
173
+ // `BoardAssetError` carrying only path/source metadata — never the auth
174
+ // token, which has not been generated yet. The CLI's `instanceof
175
+ // BoardAssetError` branch translates this into a clean operator error
176
+ // without leaking secrets through machine output or logs.
177
+ const assetRoot: BoardAssetRoot = resolveBoardAssetRoot(
178
+ options.assetRootOverride === undefined
179
+ ? {}
180
+ : { assetRootOverride: options.assetRootOverride },
181
+ );
182
+ const boardRoot: string = assetRoot.assetRoot;
155
183
  const database: TrekoonDatabase = openTrekoonDatabase(cwd);
156
184
  const paths = resolveStoragePaths(cwd);
157
- const boardRoot: string = paths.boardDir;
158
185
  const stateFile: string = resolve(paths.storageDir, BOARD_SERVER_STATE_FILENAME);
159
- const token: string = options.token ?? randomBytes(18).toString("hex");
186
+ const token: string = options.token ?? randomBytes(32).toString("hex");
160
187
  const eventBus: BoardEventBus = createBoardEventBus();
161
188
  const walWatcher: WalWatcher = startWalWatcher({
162
189
  db: database.db,
@@ -195,15 +222,15 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
195
222
  // and Referer headers free of the secret on the very first
196
223
  // navigation, severing the leakage surface that an open URL bar
197
224
  // would otherwise expose. The cookie carries auth from here on.
198
- const redirectUrl = new URL(url);
199
- redirectUrl.searchParams.delete("token");
200
- // Preserve the relative location so the redirect works regardless
201
- // of how the client reached us (loopback IP vs. localhost name).
202
- const location = `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
225
+ // Preserve a single-slash relative location so the redirect works
226
+ // regardless of how the client reached us, without creating a
227
+ // scheme-relative `//host` Location for odd incoming paths.
228
+ const location = buildTokenStrippedLocation(url);
203
229
  return new Response(null, {
204
230
  status: 302,
205
231
  headers: {
206
232
  ...responseHeaders,
233
+ "referrer-policy": "no-referrer",
207
234
  location: location.length > 0 ? location : "/",
208
235
  },
209
236
  });
@@ -34,6 +34,7 @@ interface BoardSnapshotSubtask {
34
34
  readonly owner: string | null;
35
35
  readonly createdAt: number;
36
36
  readonly updatedAt: number;
37
+ readonly version: number;
37
38
  readonly blockedBy: readonly string[];
38
39
  readonly blocks: readonly string[];
39
40
  readonly dependencyIds: readonly string[];
@@ -51,6 +52,7 @@ interface BoardSnapshotTask {
51
52
  readonly owner: string | null;
52
53
  readonly createdAt: number;
53
54
  readonly updatedAt: number;
55
+ readonly version: number;
54
56
  readonly blockedBy: readonly string[];
55
57
  readonly blocks: readonly string[];
56
58
  readonly dependencyIds: readonly string[];
@@ -66,6 +68,7 @@ interface BoardSnapshotEpic {
66
68
  readonly status: string;
67
69
  readonly createdAt: number;
68
70
  readonly updatedAt: number;
71
+ readonly version: number;
69
72
  readonly taskIds: readonly string[];
70
73
  readonly counts: FlatCounts;
71
74
  readonly searchText: string;
@@ -169,6 +172,7 @@ function mapSnapshotSubtask(subtask: SubtaskRecord, indexes: ReturnType<typeof b
169
172
  owner: subtask.owner ?? null,
170
173
  createdAt: subtask.createdAt,
171
174
  updatedAt: subtask.updatedAt,
175
+ version: subtask.version,
172
176
  blockedBy: indexes.blockedByIdsBySource.get(subtask.id) ?? [],
173
177
  blocks: indexes.blocksByTarget.get(subtask.id) ?? [],
174
178
  dependencyIds: indexes.dependencyIdsBySource.get(subtask.id) ?? [],
@@ -188,6 +192,7 @@ function mapSnapshotTask(task: TaskRecord, taskSubtasks: readonly BoardSnapshotS
188
192
  owner: task.owner ?? null,
189
193
  createdAt: task.createdAt,
190
194
  updatedAt: task.updatedAt,
195
+ version: task.version,
191
196
  blockedBy: indexes.blockedByIdsBySource.get(task.id) ?? [],
192
197
  blocks: indexes.blocksByTarget.get(task.id) ?? [],
193
198
  dependencyIds: indexes.dependencyIdsBySource.get(task.id) ?? [],
@@ -205,6 +210,7 @@ function mapSnapshotEpic(epic: EpicRecord, epicTasks: readonly BoardSnapshotTask
205
210
  status: epic.status,
206
211
  createdAt: epic.createdAt,
207
212
  updatedAt: epic.updatedAt,
213
+ version: epic.version,
208
214
  taskIds: epicTasks.map((task) => task.id),
209
215
  counts: deriveFlatCounts(epicTasks),
210
216
  searchText: [epic.title, epic.description, ...epicTasks.map((task) => task.searchText)].join(" ").toLowerCase(),
@@ -1,42 +1,10 @@
1
- export const BOARD_ASSET_CONTRACT_VERSION = "1.0.0";
2
- export const BOARD_BUNDLED_ASSET_DIRNAME = "assets";
3
-
4
- export interface BoardAssetManifest {
5
- readonly contractVersion: string;
6
- readonly assetVersion: string;
7
- readonly entryFile: string;
8
- readonly files: readonly string[];
9
- readonly assetDigest: string;
10
- }
11
-
12
- export interface BoardAssetPaths {
13
- readonly sourceRoot: string;
14
- readonly runtimeRoot: string;
15
- readonly entryFile: string;
16
- readonly manifestFile: string;
17
- }
18
-
19
- export type BoardInstallAction = "installed" | "reinstalled" | "updated" | "unchanged";
20
-
21
- export interface BoardInstallResult {
22
- readonly action: BoardInstallAction;
23
- readonly paths: BoardAssetPaths;
24
- readonly manifest: BoardAssetManifest;
25
- }
26
-
27
- export interface EnsureBoardInstalledOptions {
28
- readonly workingDirectory?: string;
29
- readonly assetVersion?: string;
30
- readonly bundledAssetRoot?: string;
31
- }
32
-
33
- export class BoardInstallError extends Error {
1
+ export class BoardAssetError extends Error {
34
2
  readonly code: string;
35
3
  readonly details: Record<string, unknown>;
36
4
 
37
5
  constructor(code: string, message: string, details: Record<string, unknown> = {}) {
38
6
  super(message);
39
- this.name = "BoardInstallError";
7
+ this.name = "BoardAssetError";
40
8
  this.code = code;
41
9
  this.details = details;
42
10
  }
@@ -22,6 +22,8 @@ import { TrackerDomain } from "../domain/tracker-domain";
22
22
  import { type BoardEventBus } from "./event-bus";
23
23
  import { buildBoardSnapshot, type BoardSnapshot } from "./snapshot";
24
24
 
25
+ const IN_PROCESS_WAL_SUPPRESS_MS = 500;
26
+
25
27
  interface CollectionDiff {
26
28
  readonly upserted: unknown[];
27
29
  readonly deletedIds: string[];
@@ -64,6 +66,65 @@ function changeKeyEqual(
64
66
  return a.version === b.version && a.updatedAt === b.updatedAt;
65
67
  }
66
68
 
69
+ function derivedRecordFingerprint(value: unknown): string {
70
+ if (!value || typeof value !== "object") {
71
+ return JSON.stringify(value);
72
+ }
73
+
74
+ const record = value as Record<string, unknown>;
75
+ const kind = typeof record.kind === "string" ? record.kind : "";
76
+
77
+ if (kind === "task") {
78
+ return JSON.stringify({
79
+ blockedBy: record.blockedBy,
80
+ blocks: record.blocks,
81
+ dependencyIds: record.dependencyIds,
82
+ dependentIds: record.dependentIds,
83
+ subtasks: record.subtasks,
84
+ searchText: record.searchText,
85
+ });
86
+ }
87
+
88
+ if (kind === "subtask") {
89
+ return JSON.stringify({
90
+ blockedBy: record.blockedBy,
91
+ blocks: record.blocks,
92
+ dependencyIds: record.dependencyIds,
93
+ dependentIds: record.dependentIds,
94
+ searchText: record.searchText,
95
+ });
96
+ }
97
+
98
+ if ("taskIds" in record || "counts" in record) {
99
+ return JSON.stringify({
100
+ taskIds: record.taskIds,
101
+ counts: record.counts,
102
+ searchText: record.searchText,
103
+ });
104
+ }
105
+
106
+ return JSON.stringify(record);
107
+ }
108
+
109
+ function recordMatchesPublishedDelta(record: unknown, publishedRecord: unknown): boolean {
110
+ const recordKey = recordChangeKey(record);
111
+ const publishedKey = recordChangeKey(publishedRecord);
112
+ return changeKeyEqual(recordKey, publishedKey) &&
113
+ derivedRecordFingerprint(record) === derivedRecordFingerprint(publishedRecord);
114
+ }
115
+
116
+ function recordChanged(previousRecord: unknown, currentRecord: unknown): boolean {
117
+ if (!changeKeyEqual(recordChangeKey(previousRecord), recordChangeKey(currentRecord))) {
118
+ return true;
119
+ }
120
+
121
+ // The board snapshot includes derived parent fields (for example epic task
122
+ // counts/search text and task subtask lists). Child writes do not bump the
123
+ // parent row version, but those derived fields still need to reach connected
124
+ // boards through WAL deltas.
125
+ return derivedRecordFingerprint(previousRecord) !== derivedRecordFingerprint(currentRecord);
126
+ }
127
+
67
128
  function diffById(previous: readonly unknown[] | undefined, current: readonly unknown[] | undefined): CollectionDiff {
68
129
  const previousIndex = new Map<string, unknown>();
69
130
  for (const record of previous ?? []) {
@@ -87,9 +148,7 @@ function diffById(previous: readonly unknown[] | undefined, current: readonly un
87
148
  upserted.push(record);
88
149
  continue;
89
150
  }
90
- // Tuple compare on (version, updatedAt) — both move in lockstep on every
91
- // domain write. Equal tuple → no content change → skip the upsert.
92
- if (!changeKeyEqual(recordChangeKey(previousRecord), recordChangeKey(record))) {
151
+ if (recordChanged(previousRecord, record)) {
93
152
  upserted.push(record);
94
153
  }
95
154
  }
@@ -104,6 +163,66 @@ function diffById(previous: readonly unknown[] | undefined, current: readonly un
104
163
  return { upserted, deletedIds };
105
164
  }
106
165
 
166
+ function recordsByIdFromDelta(delta: Record<string, unknown> | null, key: string): Map<string, unknown> {
167
+ const records = delta?.[key];
168
+ const index = new Map<string, unknown>();
169
+ if (!Array.isArray(records)) {
170
+ return index;
171
+ }
172
+
173
+ for (const record of records) {
174
+ const id = recordId(record);
175
+ if (id !== null) {
176
+ index.set(id, record);
177
+ }
178
+ }
179
+
180
+ return index;
181
+ }
182
+
183
+ function deletedIdsFromDelta(delta: Record<string, unknown> | null, key: string): Set<string> {
184
+ const ids = delta?.[key];
185
+ if (!Array.isArray(ids)) {
186
+ return new Set();
187
+ }
188
+
189
+ return new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0));
190
+ }
191
+
192
+ function suppressAlreadyPublishedDiff(
193
+ diff: CollectionDiff,
194
+ publishedRecords: Map<string, unknown>,
195
+ publishedDeletedIds: Set<string>,
196
+ ): CollectionDiff {
197
+ return {
198
+ upserted: diff.upserted.filter((record) => {
199
+ const id = recordId(record);
200
+ if (id === null) {
201
+ return true;
202
+ }
203
+
204
+ const publishedRecord = publishedRecords.get(id);
205
+ return publishedRecord === undefined || !recordMatchesPublishedDelta(record, publishedRecord);
206
+ }),
207
+ deletedIds: diff.deletedIds.filter((id) => !publishedDeletedIds.has(id)),
208
+ };
209
+ }
210
+
211
+ function hasDiffChanges(...diffs: readonly CollectionDiff[]): boolean {
212
+ return diffs.some((diff) => diff.upserted.length > 0 || diff.deletedIds.length > 0);
213
+ }
214
+
215
+ function readMtime(path: string): number {
216
+ if (!existsSync(path)) {
217
+ return 0;
218
+ }
219
+ try {
220
+ return statSync(path).mtimeMs;
221
+ } catch {
222
+ return 0;
223
+ }
224
+ }
225
+
107
226
  export interface WalWatcherOptions {
108
227
  readonly db: Database;
109
228
  readonly databaseFile: string;
@@ -168,25 +287,59 @@ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
168
287
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
169
288
  let closed = false;
170
289
  let failures = 0;
290
+ let lastSuppressedInProcessWriteAt = 0;
171
291
 
172
292
  function reconcile(): void {
173
293
  if (closed) {
174
294
  return;
175
295
  }
296
+ const inProcessWriteAt = options.eventBus.lastInProcessWriteAt;
297
+ const shouldSuppressInProcessTick =
298
+ inProcessWriteAt > lastSuppressedInProcessWriteAt &&
299
+ Date.now() - inProcessWriteAt <= IN_PROCESS_WAL_SUPPRESS_MS;
176
300
 
177
301
  try {
178
302
  const fresh = buildSnapshot(domain);
179
-
180
303
  const epicsDiff = diffById(lastSnapshot.epics, fresh.epics);
181
304
  const tasksDiff = diffById(lastSnapshot.tasks, fresh.tasks);
182
305
  const subtasksDiff = diffById(lastSnapshot.subtasks, fresh.subtasks);
183
306
  const dependenciesDiff = diffById(lastSnapshot.dependencies, fresh.dependencies);
184
307
 
185
- const hasChanges =
186
- epicsDiff.upserted.length > 0 || epicsDiff.deletedIds.length > 0 ||
187
- tasksDiff.upserted.length > 0 || tasksDiff.deletedIds.length > 0 ||
188
- subtasksDiff.upserted.length > 0 || subtasksDiff.deletedIds.length > 0 ||
189
- dependenciesDiff.upserted.length > 0 || dependenciesDiff.deletedIds.length > 0;
308
+ const shouldSuppressDiff = shouldSuppressInProcessTick
309
+ ? {
310
+ epics: suppressAlreadyPublishedDiff(
311
+ epicsDiff,
312
+ recordsByIdFromDelta(options.eventBus.lastInProcessSnapshotDelta, "epics"),
313
+ deletedIdsFromDelta(options.eventBus.lastInProcessSnapshotDelta, "deletedEpicIds"),
314
+ ),
315
+ tasks: suppressAlreadyPublishedDiff(
316
+ tasksDiff,
317
+ recordsByIdFromDelta(options.eventBus.lastInProcessSnapshotDelta, "tasks"),
318
+ deletedIdsFromDelta(options.eventBus.lastInProcessSnapshotDelta, "deletedTaskIds"),
319
+ ),
320
+ subtasks: suppressAlreadyPublishedDiff(
321
+ subtasksDiff,
322
+ recordsByIdFromDelta(options.eventBus.lastInProcessSnapshotDelta, "subtasks"),
323
+ deletedIdsFromDelta(options.eventBus.lastInProcessSnapshotDelta, "deletedSubtaskIds"),
324
+ ),
325
+ dependencies: suppressAlreadyPublishedDiff(
326
+ dependenciesDiff,
327
+ recordsByIdFromDelta(options.eventBus.lastInProcessSnapshotDelta, "dependencies"),
328
+ deletedIdsFromDelta(options.eventBus.lastInProcessSnapshotDelta, "deletedDependencyIds"),
329
+ ),
330
+ }
331
+ : null;
332
+
333
+ if (shouldSuppressInProcessTick) {
334
+ lastSuppressedInProcessWriteAt = inProcessWriteAt;
335
+ }
336
+
337
+ const publishEpicsDiff = shouldSuppressDiff?.epics ?? epicsDiff;
338
+ const publishTasksDiff = shouldSuppressDiff?.tasks ?? tasksDiff;
339
+ const publishSubtasksDiff = shouldSuppressDiff?.subtasks ?? subtasksDiff;
340
+ const publishDependenciesDiff = shouldSuppressDiff?.dependencies ?? dependenciesDiff;
341
+
342
+ const hasChanges = hasDiffChanges(publishEpicsDiff, publishTasksDiff, publishSubtasksDiff, publishDependenciesDiff);
190
343
 
191
344
  lastSnapshot = fresh;
192
345
 
@@ -197,14 +350,14 @@ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
197
350
  options.eventBus.publishSnapshotDelta({
198
351
  generatedAt: Date.now(),
199
352
  source: "wal-watcher",
200
- epics: epicsDiff.upserted,
201
- tasks: tasksDiff.upserted,
202
- subtasks: subtasksDiff.upserted,
203
- dependencies: dependenciesDiff.upserted,
204
- deletedEpicIds: epicsDiff.deletedIds,
205
- deletedTaskIds: tasksDiff.deletedIds,
206
- deletedSubtaskIds: subtasksDiff.deletedIds,
207
- deletedDependencyIds: dependenciesDiff.deletedIds,
353
+ epics: publishEpicsDiff.upserted,
354
+ tasks: publishTasksDiff.upserted,
355
+ subtasks: publishSubtasksDiff.upserted,
356
+ dependencies: publishDependenciesDiff.upserted,
357
+ deletedEpicIds: publishEpicsDiff.deletedIds,
358
+ deletedTaskIds: publishTasksDiff.deletedIds,
359
+ deletedSubtaskIds: publishSubtasksDiff.deletedIds,
360
+ deletedDependencyIds: publishDependenciesDiff.deletedIds,
208
361
  });
209
362
  } catch (error) {
210
363
  // Reconciliation must never crash the server. Errors here usually mean
@@ -235,17 +388,6 @@ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
235
388
  // than spurious watcher fires (e.g. atime-only updates on some filesystems).
236
389
  let lastWalMtime: number = readMtime(walFile);
237
390
 
238
- function readMtime(path: string): number {
239
- if (!existsSync(path)) {
240
- return 0;
241
- }
242
- try {
243
- return statSync(path).mtimeMs;
244
- } catch {
245
- return 0;
246
- }
247
- }
248
-
249
391
  function maybeScheduleReconcile(): void {
250
392
  const currentMtime = readMtime(walFile);
251
393
  // mtime can equal 0 when the WAL was just checkpointed and removed; treat
@@ -1,9 +1,9 @@
1
1
  import { parseArgs, readUnexpectedPositionals } from "./arg-parser";
2
2
 
3
- import { ensureBoardInstalled, updateBoardInstallation } from "../board/install";
3
+ import { resolveBoardAssetRoot, type BoardAssetRoot, type ResolveBoardAssetRootOptions } from "../board/asset-root";
4
4
  import { openBoardInBrowser, type OpenBrowserResult } from "../board/open-browser";
5
5
  import { startBoardServer, type BoardServerInfo } from "../board/server";
6
- import { BoardInstallError, type EnsureBoardInstalledOptions } from "../board/types";
6
+ import { BoardAssetError } from "../board/types";
7
7
  import { failResult, okResult } from "../io/output";
8
8
  import { type CliContext, type CliResult } from "../runtime/command-types";
9
9
 
@@ -11,26 +11,23 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
11
11
  // command modules: each subcommand declares the set of flags (and options)
12
12
  // it accepts; everything else is rejected up-front.
13
13
  const OPEN_FLAGS = ["reveal-token"] as const;
14
- const UPDATE_FLAGS: readonly string[] = [];
15
14
 
16
15
  const FLAGS_BY_SUBCOMMAND: Readonly<Record<string, readonly string[]>> = {
17
16
  open: OPEN_FLAGS,
18
- update: UPDATE_FLAGS,
19
17
  };
20
18
 
21
- type EnsureBoardInstalledFn = (options: EnsureBoardInstalledOptions) => ReturnType<typeof ensureBoardInstalled>;
22
- type StartBoardServerFn = (options: { cwd: string }) => BoardServerInfo;
19
+ type ResolveAssetRootFn = (options: ResolveBoardAssetRootOptions) => BoardAssetRoot;
20
+ type StartBoardServerFn = (options: { cwd: string; assetRootOverride?: string }) => BoardServerInfo;
23
21
  type OpenBoardInBrowserFn = (url: string) => Promise<OpenBrowserResult> | OpenBrowserResult;
24
22
 
25
- let ensureInstalledImpl: EnsureBoardInstalledFn = ensureBoardInstalled;
26
- let updateInstalledImpl: EnsureBoardInstalledFn = updateBoardInstallation;
23
+ let resolveAssetRootImpl: ResolveAssetRootFn = resolveBoardAssetRoot;
27
24
  let startBoardServerImpl: StartBoardServerFn = (options) => startBoardServer(options);
28
25
  let openBoardInBrowserImpl: OpenBoardInBrowserFn = openBoardInBrowser;
29
26
 
30
27
  function usageResult(): CliResult {
31
28
  return failResult({
32
29
  command: "board",
33
- human: "Usage: trekoon board <open|update>",
30
+ human: "Usage: trekoon board <open>",
34
31
  data: {},
35
32
  error: {
36
33
  code: "invalid_subcommand",
@@ -39,15 +36,7 @@ function usageResult(): CliResult {
39
36
  });
40
37
  }
41
38
 
42
- function boardInstallOptions(context: CliContext): EnsureBoardInstalledOptions {
43
- const bundledAssetRoot: string | undefined = process.env.TREKOON_BOARD_ASSET_ROOT;
44
- return {
45
- workingDirectory: context.cwd,
46
- ...(bundledAssetRoot === undefined ? {} : { bundledAssetRoot }),
47
- };
48
- }
49
-
50
- function boardInstallFailure(command: string, error: BoardInstallError): CliResult {
39
+ function boardAssetFailure(command: string, error: BoardAssetError): CliResult {
51
40
  return failResult({
52
41
  command,
53
42
  human: error.message,
@@ -63,13 +52,11 @@ function boardInstallFailure(command: string, error: BoardInstallError): CliResu
63
52
  }
64
53
 
65
54
  export function setBoardCommandHooksForTests(hooks: {
66
- ensureInstalled?: EnsureBoardInstalledFn;
67
- updateInstalled?: EnsureBoardInstalledFn;
55
+ resolveAssetRoot?: ResolveAssetRootFn;
68
56
  startBoardServer?: StartBoardServerFn;
69
57
  openBoardInBrowser?: OpenBoardInBrowserFn;
70
58
  } | null): void {
71
- ensureInstalledImpl = hooks?.ensureInstalled ?? ensureBoardInstalled;
72
- updateInstalledImpl = hooks?.updateInstalled ?? updateBoardInstallation;
59
+ resolveAssetRootImpl = hooks?.resolveAssetRoot ?? resolveBoardAssetRoot;
73
60
  startBoardServerImpl = hooks?.startBoardServer ?? ((options) => startBoardServer(options));
74
61
  openBoardInBrowserImpl = hooks?.openBoardInBrowser ?? openBoardInBrowser;
75
62
  }
@@ -121,21 +108,12 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
121
108
 
122
109
  try {
123
110
  switch (subcommand) {
124
- case "update": {
125
- const install = updateInstalledImpl(boardInstallOptions(context));
126
- return okResult({
127
- command: "board.update",
128
- human: `Board assets ${install.action} at ${install.paths.runtimeRoot}`,
129
- data: {
130
- action: install.action,
131
- paths: install.paths,
132
- manifest: install.manifest,
133
- },
134
- });
135
- }
136
111
  case "open": {
137
- const install = ensureInstalledImpl(boardInstallOptions(context));
138
- const server = startBoardServerImpl({ cwd: context.cwd });
112
+ const assetRoot = resolveAssetRootImpl({});
113
+ const server = startBoardServerImpl({
114
+ cwd: context.cwd,
115
+ assetRootOverride: assetRoot.assetRoot,
116
+ });
139
117
  const launch = await openBoardInBrowserImpl(server.url);
140
118
  const humanLines: string[] = [
141
119
  `Board ready at ${server.fallbackUrl}`,
@@ -170,10 +148,10 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
170
148
  command: "board.open",
171
149
  human: humanLines.join("\n"),
172
150
  data: {
173
- install: {
174
- action: install.action,
175
- paths: install.paths,
176
- manifest: install.manifest,
151
+ assetRoot: {
152
+ path: assetRoot.assetRoot,
153
+ entryFile: assetRoot.entryFile,
154
+ source: assetRoot.source,
177
155
  },
178
156
  server: serverData,
179
157
  launch: launchData,
@@ -184,8 +162,8 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
184
162
  return usageResult();
185
163
  }
186
164
  } catch (error: unknown) {
187
- if (error instanceof BoardInstallError) {
188
- return boardInstallFailure(`board.${subcommand}`, error);
165
+ if (error instanceof BoardAssetError) {
166
+ return boardAssetFailure(`board.${subcommand}`, error);
189
167
  }
190
168
 
191
169
  throw error;
@@ -22,7 +22,7 @@ const ROOT_HELP = [
22
22
  " init Create or re-bootstrap .trekoon storage",
23
23
  " quickstart Storage model, agent loop, and common patterns",
24
24
  " wipe Delete repo-wide .trekoon state (requires --yes)",
25
- " board Open or update the local board",
25
+ " board Open the local board",
26
26
  " epic Create, list, update, search, and track epics",
27
27
  " task Create, list, update, search, and complete tasks",
28
28
  " subtask Create, list, update, and search subtasks",
@@ -89,12 +89,12 @@ const WIPE_HELP = [
89
89
  ].join("\n");
90
90
 
91
91
  const BOARD_HELP = [
92
- "Usage: trekoon board <open|update>",
92
+ "Usage: trekoon board <open>",
93
93
  "",
94
94
  "Subcommands:",
95
- " open Install board assets, start a local server on 127.0.0.1,",
96
- " and open the browser. Returns the board URL and a fallback URL.",
97
- " update Refresh board runtime assets only. No server, no browser.",
95
+ " open Start a local board server on 127.0.0.1 and open the browser.",
96
+ " Returns the board URL and a fallback URL. Board assets are served",
97
+ " directly from the running Trekoon package; no per-repo install.",
98
98
  "",
99
99
  "Token visibility:",
100
100
  " By default the board token is redacted from machine output (shown as ****).",
@@ -108,20 +108,19 @@ const BOARD_HELP = [
108
108
  "Examples:",
109
109
  " trekoon board open",
110
110
  " trekoon board open --reveal-token",
111
- " trekoon --json board update",
112
111
  ].join("\n");
113
112
 
114
113
  const EPIC_HELP = [
115
114
  "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress|export> [options]",
116
115
  "",
117
116
  "Create:",
118
- " trekoon epic create --title \"...\" --description \"...\" [--status <status>]",
119
- " trekoon epic create --title \"...\" --description \"...\" [--task <spec>] [--subtask <spec>] [--dep <spec>]",
117
+ " trekoon --toon epic create --title \"...\" --description \"...\" [--status <status>]",
118
+ " trekoon --toon epic create --title \"...\" --description \"...\" [--task <spec>] [--subtask <spec>] [--dep <spec>]",
120
119
  " When the full tree is known, the second form creates everything in one shot",
121
120
  " and returns mappings/counts. Same compact spec grammar as epic expand.",
122
121
  "",
123
122
  "Expand:",
124
- " trekoon epic expand <epic-id> [--task <spec>] [--subtask <spec>] [--dep <spec>]",
123
+ " trekoon --toon epic expand <epic-id> [--task <spec>] [--subtask <spec>] [--dep <spec>]",
125
124
  " --task <temp-key>|<title>|<description>|<status>",
126
125
  ` --subtask <parent-ref>|<temp-key>|<title>|<description>|<status> (${"@"}<temp-key> for new parents)`,
127
126
  ` --dep <source-ref>|<depends-on-ref> (refs can be IDs or ${"@"}<temp-key>)`,
@@ -441,8 +440,8 @@ const SUGGEST_HELP = [
441
440
 
442
441
  const SKILLS_HELP = [
443
442
  "Usage:",
444
- " trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]",
445
- " trekoon skills install -g|--global [--editor opencode|claude|pi]",
443
+ " trekoon skills install [--link --editor opencode|claude|codex|pi] [--to <path>] [--allow-outside-repo]",
444
+ " trekoon skills install -g|--global [--editor opencode|claude|codex|pi]",
446
445
  " trekoon skills update",
447
446
  "",
448
447
  "Installs or refreshes the Trekoon skill so AI agents can plan and execute.",
@@ -451,7 +450,7 @@ const SKILLS_HELP = [
451
450
  " Creates a symlink at .agents/skills/trekoon pointing to the bundled source,",
452
451
  " so the skill always matches the installed CLI version.",
453
452
  " --link Also create an editor symlink named 'trekoon'.",
454
- " --editor <name> Required with --link (opencode|claude|pi).",
453
+ " --editor <name> Required with --link (opencode|claude|codex|pi).",
455
454
  " --to <path> Override the symlink root for --link only.",
456
455
  " --allow-outside-repo Allow links outside the repo (requires --link).",
457
456
  "",