trekoon 0.4.3 → 0.4.5

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.
@@ -98,6 +98,10 @@ matches the user intent.
98
98
  `task next`, `dep list`, `dep reverse`, targeted `show`.
99
99
  - Prefer transactional/bulk commands for planning and narrow `--ids` for bulk
100
100
  updates.
101
+ - In Claude Code, keep parallel `Bash` batches read-only for Trekoon commands.
102
+ Only `task claim` and `subtask claim` are safe parallel write exceptions.
103
+ Run `task done` and other status-changing commands sequentially after reading
104
+ current state.
101
105
  - Append progress, verification, and blocker notes with `--append`; do not
102
106
  rewrite descriptions unless fixing the plan itself.
103
107
  - Preview search/replace before `--apply`.
@@ -112,7 +116,8 @@ matches the user intent.
112
116
  Normal status flow is `todo -> in_progress -> done`; `blocked` requires an
113
117
  appended reason. Use `task done` for task completion because it auto-transitions
114
118
  from `todo` or `blocked` through `in_progress`. Load
115
- `reference/status-machine.md` for transition errors or uncertainty.
119
+ `reference/status-machine.md` for transition errors or uncertainty. For
120
+ subtasks, claim or move through `in_progress` before marking `done`.
116
121
 
117
122
  ## Recovery
118
123
 
@@ -47,7 +47,10 @@ TaskCreate:
47
47
  While working:
48
48
  - Complete required subtasks.
49
49
  - Append progress notes; do not rewrite task descriptions.
50
- - Use task done for completion.
50
+ - Use task done for task completion.
51
+ - For subtasks, claim or move through in_progress before done.
52
+ - Keep parallel Trekoon Bash calls read-only; serialize status-changing
53
+ commands unless using atomic claim.
51
54
  - Use --compact for noisy Trekoon reads.
52
55
 
53
56
  On completion:
@@ -82,7 +85,9 @@ Agent:
82
85
  Claim each Trekoon task before editing:
83
86
  trekoon --toon task claim <trekoon-task-id> --owner <your-name>
84
87
 
85
- Use task done for completion. Read and report unblocked tasks, warnings,
88
+ Use task done for task completion. For subtasks, claim or move through
89
+ in_progress before done. Do not batch multiple Trekoon status-changing Bash
90
+ calls in one parallel tool turn. Read and report unblocked tasks, warnings,
86
91
  and next candidate via SendMessage.
87
92
 
88
93
  Communicate blockers and coordination needs via SendMessage.
@@ -126,6 +131,10 @@ For `status_transition_invalid`, inspect current status with:
126
131
  trekoon --toon --compact task show <id>
127
132
  ```
128
133
 
134
+ If the error came from a cancelled parallel Bash batch, first re-read the
135
+ affected task or subtask, then retry only the valid next transition. Do not
136
+ replay the whole batch.
137
+
129
138
  For `dependency_blocked`, inspect the dependency, append a blocker note, then
130
139
  continue with a ready candidate from:
131
140
 
@@ -86,7 +86,8 @@ Before each task:
86
86
  While working:
87
87
  - Complete required subtasks and update subtask statuses.
88
88
  - Append meaningful progress notes; do not rewrite task descriptions.
89
- - Respect status flow: todo -> in_progress -> done. Use task done for completion.
89
+ - Respect status flow: todo -> in_progress -> done. Use task done for task
90
+ completion; for subtasks, claim or move through in_progress before done.
90
91
  - Assume other agents may edit unrelated files. Do not revert unrelated changes.
91
92
 
92
93
  On completion:
@@ -102,6 +103,16 @@ If blocked:
102
103
  Use --compact in noisy Trekoon reads. Do not create branches, commits, pushes,
103
104
  or PRs unless the user explicitly asked and harness policy allows it.
104
105
 
106
+ Claude Code parallel tool calls:
107
+ - Parallel Trekoon Bash calls are for read-only commands such as session,
108
+ progress, ready, suggest, and targeted show.
109
+ - Do not issue multiple Trekoon status-changing Bash commands in one parallel
110
+ tool batch. Use task/subtask claim for atomic races, then serialize updates
111
+ and completion commands.
112
+ - If a parallel batch reports sibling cancellation, re-read the affected task or
113
+ subtask before retrying; recover from current Trekoon state, not the cancelled
114
+ command text.
115
+
105
116
  Final report: tasks completed, files changed, checks, review result/gap,
106
117
  task done response, blockers.
107
118
  ```
@@ -46,7 +46,10 @@ coordinating from the parent session?
46
46
  `wait_agent`, `resume_agent`, and `close_agent`.
47
47
  - Claude Code: use normal subagents for bounded side work. Use Agent Teams only
48
48
  when the user explicitly asks for team execution and the environment supports
49
- it.
49
+ it. Treat parallel `Bash` tool calls as read-only unless every command is a
50
+ safe atomic claim. Do not batch multiple Trekoon status-changing commands in
51
+ one parallel tool turn; run them sequentially so one failed transition does
52
+ not cancel sibling mutations.
50
53
  - OpenCode: use `@explore` for read-only discovery and `@general` or native
51
54
  Task for write-capable lane work. Use `question` when available.
52
55
  - Pi/other harnesses: use the same intent and native task/subagent/question
package/README.md CHANGED
@@ -196,14 +196,19 @@ call it from any non-done status.
196
196
 
197
197
  ## Local board
198
198
 
199
- Trekoon includes a browser-based board for humans who like having visual overview.
200
- No build step, no framework dependencies, works offline.
199
+ Trekoon includes a browser-based board for humans who like having a visual
200
+ overview. No build step, no framework dependencies, works offline.
201
201
 
202
202
  ```bash
203
203
  trekoon board open # starts a local server, opens browser
204
- trekoon board update # refresh assets only
205
204
  ```
206
205
 
206
+ Board code (HTML, JS, CSS, fonts) comes from the running Trekoon install.
207
+ Board data comes from the repo where `trekoon board open` is invoked. Updating
208
+ Trekoon updates the board bundle; no per-repo asset copy is needed.
209
+ Repos with an old ignored `.trekoon/board` directory keep those files on disk,
210
+ but Trekoon no longer reads or refreshes them.
211
+
207
212
  Binds to `127.0.0.1` only with a per-session token. Gives you an epics
208
213
  overview, kanban workspace per epic, task detail modals, and search. The board
209
214
  client subscribes to `/api/snapshot/stream` (SSE), so mutations from another
package/docs/ai-agents.md CHANGED
@@ -274,6 +274,14 @@ trekoon --toon epic replace <epic-id> --search "path/to/somewhere" --replace "pa
274
274
  trekoon --toon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path" --apply
275
275
  ```
276
276
 
277
+ For Claude Code and similar harnesses with parallel tool calls, keep parallel
278
+ Trekoon `Bash` batches read-only. It is safe to fan out `session`, `progress`,
279
+ `ready`, `suggest`, and targeted `show` commands. Do not batch multiple
280
+ status-changing Trekoon commands in one parallel tool turn; if one transition
281
+ fails, sibling calls may be cancelled and the agent must re-read state before
282
+ retrying. Use `task claim` / `subtask claim` for atomic races, then serialize
283
+ status updates and completion commands.
284
+
277
285
  ## Shared-database model
278
286
 
279
287
  Trekoon uses one live SQLite database per repository at
package/docs/commands.md CHANGED
@@ -6,7 +6,7 @@ the quickest way to get started, read [Quickstart](quickstart.md) first.
6
6
  ## Command surface
7
7
 
8
8
  - `trekoon init`
9
- - `trekoon board <open|update>`
9
+ - `trekoon board <open>`
10
10
  - `trekoon help [command]`
11
11
  - `trekoon quickstart`
12
12
  - `trekoon epic <create|expand|list|show|search|replace|update|delete|progress>`
@@ -50,25 +50,31 @@ also accept `-h` and `-v`.
50
50
 
51
51
  ## Board commands
52
52
 
53
- `trekoon board open` installs board assets (if needed), starts a loopback-only
54
- server on `127.0.0.1` with a random port, opens the browser, and returns the
55
- board URL plus a manual fallback URL.
53
+ `trekoon board open` starts a loopback-only server on `127.0.0.1` with a
54
+ random port, opens the browser, and returns the board URL plus a manual
55
+ fallback URL.
56
56
 
57
- `trekoon board update` refreshes board runtime assets without starting the
58
- server or opening a browser. Use this when you need to update copied assets
59
- before the next launch.
57
+ Board code (HTML, JS, CSS, fonts) comes from the running Trekoon install —
58
+ a global install serves the global package assets; a project-local install
59
+ serves the project-local package assets. Board data comes from the repo where
60
+ `trekoon board open` is invoked. Updating Trekoon updates the board bundle;
61
+ no per-repo asset copy is needed.
60
62
 
61
63
  Security model:
62
64
 
63
65
  - Every `board open` session gets a unique token
64
- - Requests must include it as `Authorization: Bearer {token}`,
65
- `x-trekoon-token` header, or `?token={token}` query parameter
66
+ - Initial board HTML bootstrap accepts `?token={token}` once, sets an
67
+ HttpOnly `trekoon_board_session` cookie, and redirects to the same URL
68
+ without the token query parameter
69
+ - Board API requests must authenticate with the session cookie,
70
+ `Authorization: Bearer {token}`, or `x-trekoon-token` header
71
+ - API routes reject token query parameters
72
+ - `/api/snapshot/stream` is authenticated by the session cookie
66
73
  - Invalid tokens return `401`
67
74
  - Static responses use `cache-control: no-store`
68
75
 
69
76
  The board is a self-hosted single-page app (vanilla JS, bundled CSS and fonts,
70
- no framework or CDN dependencies) served from `.trekoon/board`. Works fully
71
- offline once initialized.
77
+ no framework or CDN dependencies). Works fully offline.
72
78
 
73
79
  Board API endpoints (all require token authentication):
74
80
 
@@ -84,27 +90,30 @@ Board API endpoints (all require token authentication):
84
90
  | `POST` | `/api/dependencies` | Add dependency edge (sourceId, dependsOnId) |
85
91
  | `DELETE` | `/api/dependencies?sourceId=...&dependsOnId=...` | Remove dependency |
86
92
 
87
- **Note:** the SSE auth token rides as a `?token=` query parameter on
88
- `/api/snapshot/stream` (EventSource cannot set custom headers), so it may
89
- appear in reverse-proxy access logs; treat access logs as sensitive if the
90
- board is exposed beyond localhost.
91
-
92
93
  Board mutations from any source — board UI, CLI in another shell, or another
93
94
  worktree — propagate to every connected client through the SSE stream. The CLI
94
95
  side is driven by a WAL-watcher that diffs the snapshot when
95
96
  `.trekoon/trekoon.db-wal` changes; in-process mutations publish deltas
96
- directly. PATCH endpoints accept `If-Match: <updatedAt-ms>` for optimistic
97
- concurrency: a stale value returns `409` with `currentUpdatedAt`. Missing
98
- `If-Match` is allowed for back-compat.
99
-
100
- Board commands don't accept command-specific options yet. For tests and local
101
- development, `TREKOON_BOARD_ASSET_ROOT` overrides the bundled asset source.
97
+ directly. PATCH endpoints accept `If-Match: <version>` for optimistic
98
+ concurrency; RFC 7232 strong or `W/`-prefixed weak ETag forms are accepted,
99
+ but the `*` wildcard is not. A stale value returns `409` with
100
+ `currentVersion` and `providedVersion`. Missing `If-Match` is allowed for
101
+ back-compat.
102
+
103
+ Repos that already have an ignored `.trekoon/board` directory keep those files
104
+ on disk, but current Trekoon versions no longer read, create, refresh, or
105
+ delete them. Remove that directory manually only if you want to clean up the
106
+ old copied board bundle.
107
+
108
+ `trekoon board open` accepts `--reveal-token` to include the raw tokenized URL,
109
+ server token, and browser launch arguments in machine output. Other board
110
+ subcommands and flags are rejected. `TREKOON_BOARD_ASSET_ROOT` overrides the
111
+ asset source for tests and local development.
102
112
 
103
113
  ```bash
104
114
  trekoon init
105
115
  trekoon board open
106
116
  trekoon --json board open
107
- trekoon board update
108
117
  ```
109
118
 
110
119
  ## Human views
@@ -647,7 +647,7 @@ any `ok: false` response will be one of the following strings.
647
647
  | `orphaned_external_node` | Dependency graph contains a node that belongs to a different epic. |
648
648
  | `outside_repo_target` | Skill install target path is outside the repository root. |
649
649
  | `permission_denied` | File-system permission denied for the requested path. |
650
- | `precondition_failed` | `If-Match` precondition header did not match the entity's current `updatedAt`. |
650
+ | `precondition_failed` | `If-Match` precondition header did not match the entity's current version. Error details include `currentVersion` and `providedVersion`. |
651
651
  | `row_not_found` | Sync resolve target row no longer exists in the database. |
652
652
  | `status_transition_invalid` | Requested status transition is not permitted by the status machine. |
653
653
  | `stream_unavailable` | SSE snapshot stream is not available (board not initialised or shutting down). |
@@ -50,9 +50,8 @@ trekoon --toon init
50
50
  trekoon --toon sync status
51
51
  ```
52
52
 
53
- Run `init` once per repository. It creates the shared storage root and installs
54
- the board runtime under `.trekoon/board`. If `sync status` reports
55
- `recoveryRequired` or a tracked/ignored mismatch, fix the setup before
53
+ Run `init` once per repository. It creates the shared storage root. If `sync status`
54
+ reports `recoveryRequired` or a tracked/ignored mismatch, fix the setup before
56
55
  continuing.
57
56
 
58
57
  ## Open the board
@@ -62,9 +61,10 @@ trekoon board open
62
61
  ```
63
62
 
64
63
  Starts a loopback-only server on `127.0.0.1`, opens the browser, and prints a
65
- fallback URL. The board is a self-hosted single-page app with no CDN
66
- dependencies, so it works offline once initialized. Use `trekoon board update`
67
- if you just need to refresh the runtime assets without opening the browser.
64
+ fallback URL. Board code (HTML, JS, CSS, fonts) comes from the running Trekoon
65
+ install; board data comes from the repo where the command is invoked. The board
66
+ works fully offline. To get an updated board UI, update Trekoon itself. Old
67
+ ignored `.trekoon/board` copies are left on disk but are no longer used.
68
68
 
69
69
  ## Create work
70
70
 
@@ -163,6 +163,10 @@ The response includes `claimed` (true/false), `currentOwner`, `currentStatus`,
163
163
  and the full entity record on success. Two concurrent claims return exactly one
164
164
  `claimed=true`.
165
165
 
166
+ In Claude Code, keep parallel Trekoon `Bash` calls read-only unless the command
167
+ is an atomic `claim`. Run status updates and completion commands sequentially so
168
+ a failed transition does not cancel sibling mutations.
169
+
166
170
  ## Database backup and migration
167
171
 
168
172
  Before any manual migration recovery, snapshot the database:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
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",
@@ -40,7 +40,7 @@
40
40
  ],
41
41
  "scripts": {
42
42
  "run": "bun run ./src/index.ts",
43
- "build": "bun build ./src/index.ts --outdir ./dist --target bun",
43
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun && cp ./package.json ./dist/package.json && rm -rf ./dist/assets && cp -R ./src/board/assets ./dist/assets",
44
44
  "test": "bun test ./tests",
45
45
  "lint": "bunx tsc --noEmit"
46
46
  },
@@ -0,0 +1,73 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { BoardAssetError } from "./types";
6
+
7
+ export const BOARD_BUNDLED_ASSET_DIRNAME = "assets";
8
+ export const BOARD_ENTRY_FILENAME = "index.html";
9
+ export const BOARD_ASSET_ROOT_ENV_VAR = "TREKOON_BOARD_ASSET_ROOT";
10
+
11
+ export type BoardAssetRootSource = "override" | "environment" | "package";
12
+
13
+ export interface BoardAssetRoot {
14
+ readonly assetRoot: string;
15
+ readonly entryFile: string;
16
+ readonly source: BoardAssetRootSource;
17
+ }
18
+
19
+ export interface ResolveBoardAssetRootOptions {
20
+ readonly assetRootOverride?: string;
21
+ readonly env?: NodeJS.ProcessEnv;
22
+ }
23
+
24
+ function resolvePackageBoardAssetRoot(): string {
25
+ return fileURLToPath(new URL(`./${BOARD_BUNDLED_ASSET_DIRNAME}`, import.meta.url));
26
+ }
27
+
28
+ interface SelectedAssetRoot {
29
+ readonly rootPath: string;
30
+ readonly source: BoardAssetRootSource;
31
+ }
32
+
33
+ function selectAssetRoot(options: ResolveBoardAssetRootOptions): SelectedAssetRoot {
34
+ if (options.assetRootOverride !== undefined) {
35
+ return { rootPath: resolve(options.assetRootOverride), source: "override" };
36
+ }
37
+
38
+ const env = options.env ?? process.env;
39
+ const envOverrideRaw = env[BOARD_ASSET_ROOT_ENV_VAR];
40
+ const envOverride = typeof envOverrideRaw === "string" ? envOverrideRaw.trim() : "";
41
+ if (envOverride.length > 0) {
42
+ return { rootPath: resolve(envOverride), source: "environment" };
43
+ }
44
+
45
+ return { rootPath: resolvePackageBoardAssetRoot(), source: "package" };
46
+ }
47
+
48
+ export function resolveBoardAssetRoot(options: ResolveBoardAssetRootOptions = {}): BoardAssetRoot {
49
+ const { rootPath, source } = selectAssetRoot(options);
50
+
51
+ if (!existsSync(rootPath) || !statSync(rootPath).isDirectory()) {
52
+ throw new BoardAssetError("missing_asset", `Board asset root not found at ${rootPath}`, {
53
+ assetRoot: rootPath,
54
+ source,
55
+ });
56
+ }
57
+
58
+ const entryFile = resolve(rootPath, BOARD_ENTRY_FILENAME);
59
+ if (!existsSync(entryFile)) {
60
+ throw new BoardAssetError("missing_asset", `Board entry file not found at ${entryFile}`, {
61
+ assetRoot: rootPath,
62
+ entryFile,
63
+ source,
64
+ missingFile: BOARD_ENTRY_FILENAME,
65
+ });
66
+ }
67
+
68
+ return {
69
+ assetRoot: rootPath,
70
+ entryFile,
71
+ source,
72
+ };
73
+ }
@@ -156,7 +156,7 @@ export async function bootLegacyBoard(options = {}) {
156
156
  </div>
157
157
  <span class="${sectionLabelClasses()}">Board ready</span>
158
158
  <h1 class="mt-2 text-3xl font-semibold tracking-tight text-[var(--board-text)]">No work has been published yet</h1>
159
- <p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-[var(--board-text-muted)] sm:text-base">Once the board snapshot is installed into <code class="rounded-lg border border-[var(--board-border)] bg-white/[0.04] px-2 py-1 text-[var(--board-text)]">.trekoon/board</code>, epics and tasks will appear here.</p>
159
+ <p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-[var(--board-text-muted)] sm:text-base">Create or sync an epic in this repository and it will appear here.</p>
160
160
  </div>
161
161
  </section>
162
162
  `;
@@ -18,9 +18,10 @@ export type BoardEventListener = (event: BoardEvent) => void;
18
18
 
19
19
  export interface BoardEventBus {
20
20
  publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent;
21
- markInProcessWrite(timestamp?: number): void;
21
+ markInProcessWrite(timestamp?: number, snapshotDelta?: Record<string, unknown>): void;
22
22
  subscribe(listener: BoardEventListener): () => void;
23
23
  readonly lastInProcessWriteAt: number;
24
+ readonly lastInProcessSnapshotDelta: Record<string, unknown> | null;
24
25
  readonly subscriberCount: number;
25
26
  close(): void;
26
27
  }
@@ -30,6 +31,7 @@ export function createBoardEventBus(): BoardEventBus {
30
31
  let nextId = 1;
31
32
  let closed = false;
32
33
  let lastInProcessWriteAt = 0;
34
+ let lastInProcessSnapshotDelta: Record<string, unknown> | null = null;
33
35
 
34
36
  return {
35
37
  publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent {
@@ -54,8 +56,9 @@ export function createBoardEventBus(): BoardEventBus {
54
56
 
55
57
  return event;
56
58
  },
57
- markInProcessWrite(timestamp = Date.now()): void {
59
+ markInProcessWrite(timestamp = Date.now(), snapshotDelta: Record<string, unknown> | undefined = undefined): void {
58
60
  lastInProcessWriteAt = timestamp;
61
+ lastInProcessSnapshotDelta = snapshotDelta ?? null;
59
62
  },
60
63
  subscribe(listener: BoardEventListener): () => void {
61
64
  if (closed) {
@@ -73,6 +76,9 @@ export function createBoardEventBus(): BoardEventBus {
73
76
  get lastInProcessWriteAt(): number {
74
77
  return lastInProcessWriteAt;
75
78
  },
79
+ get lastInProcessSnapshotDelta(): Record<string, unknown> | null {
80
+ return lastInProcessSnapshotDelta;
81
+ },
76
82
  close(): void {
77
83
  closed = true;
78
84
  listeners.clear();
@@ -214,7 +214,7 @@ function publishSnapshotDeltaIfPresent(
214
214
  const delta = readSnapshotDelta(data);
215
215
  if (delta) {
216
216
  eventBus.publishSnapshotDelta(delta);
217
- eventBus.markInProcessWrite();
217
+ eventBus.markInProcessWrite(Date.now(), delta);
218
218
  }
219
219
  }
220
220
 
@@ -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 {
@@ -159,9 +168,20 @@ function injectBoardBootstrap(html: string, bootstrapJson: string): string {
159
168
 
160
169
  export function startBoardServer(options: StartBoardServerOptions = {}): BoardServerInfo {
161
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;
162
183
  const database: TrekoonDatabase = openTrekoonDatabase(cwd);
163
184
  const paths = resolveStoragePaths(cwd);
164
- const boardRoot: string = paths.boardDir;
165
185
  const stateFile: string = resolve(paths.storageDir, BOARD_SERVER_STATE_FILENAME);
166
186
  const token: string = options.token ?? randomBytes(32).toString("hex");
167
187
  const eventBus: BoardEventBus = createBoardEventBus();
@@ -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
  }
@@ -66,6 +66,65 @@ function changeKeyEqual(
66
66
  return a.version === b.version && a.updatedAt === b.updatedAt;
67
67
  }
68
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
+
69
128
  function diffById(previous: readonly unknown[] | undefined, current: readonly unknown[] | undefined): CollectionDiff {
70
129
  const previousIndex = new Map<string, unknown>();
71
130
  for (const record of previous ?? []) {
@@ -89,9 +148,7 @@ function diffById(previous: readonly unknown[] | undefined, current: readonly un
89
148
  upserted.push(record);
90
149
  continue;
91
150
  }
92
- // Tuple compare on (version, updatedAt) — both move in lockstep on every
93
- // domain write. Equal tuple → no content change → skip the upsert.
94
- if (!changeKeyEqual(recordChangeKey(previousRecord), recordChangeKey(record))) {
151
+ if (recordChanged(previousRecord, record)) {
95
152
  upserted.push(record);
96
153
  }
97
154
  }
@@ -106,6 +163,55 @@ function diffById(previous: readonly unknown[] | undefined, current: readonly un
106
163
  return { upserted, deletedIds };
107
164
  }
108
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
+
109
215
  function readMtime(path: string): number {
110
216
  if (!existsSync(path)) {
111
217
  return 0;
@@ -188,27 +294,52 @@ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
188
294
  return;
189
295
  }
190
296
  const inProcessWriteAt = options.eventBus.lastInProcessWriteAt;
191
- if (
297
+ const shouldSuppressInProcessTick =
192
298
  inProcessWriteAt > lastSuppressedInProcessWriteAt &&
193
- Date.now() - inProcessWriteAt <= IN_PROCESS_WAL_SUPPRESS_MS
194
- ) {
195
- lastSuppressedInProcessWriteAt = inProcessWriteAt;
196
- return;
197
- }
299
+ Date.now() - inProcessWriteAt <= IN_PROCESS_WAL_SUPPRESS_MS;
198
300
 
199
301
  try {
200
302
  const fresh = buildSnapshot(domain);
201
-
202
303
  const epicsDiff = diffById(lastSnapshot.epics, fresh.epics);
203
304
  const tasksDiff = diffById(lastSnapshot.tasks, fresh.tasks);
204
305
  const subtasksDiff = diffById(lastSnapshot.subtasks, fresh.subtasks);
205
306
  const dependenciesDiff = diffById(lastSnapshot.dependencies, fresh.dependencies);
206
307
 
207
- const hasChanges =
208
- epicsDiff.upserted.length > 0 || epicsDiff.deletedIds.length > 0 ||
209
- tasksDiff.upserted.length > 0 || tasksDiff.deletedIds.length > 0 ||
210
- subtasksDiff.upserted.length > 0 || subtasksDiff.deletedIds.length > 0 ||
211
- 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);
212
343
 
213
344
  lastSnapshot = fresh;
214
345
 
@@ -219,14 +350,14 @@ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
219
350
  options.eventBus.publishSnapshotDelta({
220
351
  generatedAt: Date.now(),
221
352
  source: "wal-watcher",
222
- epics: epicsDiff.upserted,
223
- tasks: tasksDiff.upserted,
224
- subtasks: subtasksDiff.upserted,
225
- dependencies: dependenciesDiff.upserted,
226
- deletedEpicIds: epicsDiff.deletedIds,
227
- deletedTaskIds: tasksDiff.deletedIds,
228
- deletedSubtaskIds: subtasksDiff.deletedIds,
229
- 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,
230
361
  });
231
362
  } catch (error) {
232
363
  // Reconciliation must never crash the server. Errors here usually mean
@@ -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,7 +108,6 @@ 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 = [
@@ -3,8 +3,6 @@ import { resolve } from "node:path";
3
3
 
4
4
  import { unexpectedFailureResult } from "./error-utils";
5
5
 
6
- import { ensureBoardInstalled } from "../board/install";
7
- import { BoardInstallError } from "../board/types";
8
6
  import { DomainError } from "../domain/types";
9
7
  import { failResult, okResult } from "../io/output";
10
8
  import { type CliContext, type CliResult } from "../runtime/command-types";
@@ -108,11 +106,6 @@ export async function runInit(context: CliContext): Promise<CliResult> {
108
106
  try {
109
107
  database = openTrekoonDatabase(context.cwd);
110
108
  const diagnostics = database.diagnostics;
111
- const bundledAssetRoot: string | undefined = process.env.TREKOON_BOARD_ASSET_ROOT;
112
- const board = ensureBoardInstalled({
113
- workingDirectory: context.cwd,
114
- ...(bundledAssetRoot === undefined ? {} : { bundledAssetRoot }),
115
- });
116
109
  const gitignoreAction: GitignoreAction = ensureGitignore(
117
110
  database.paths.storageDir,
118
111
  diagnostics.storageMode,
@@ -125,8 +118,6 @@ export async function runInit(context: CliContext): Promise<CliResult> {
125
118
  `Shared storage root: ${diagnostics.sharedStorageRoot}`,
126
119
  `Storage directory: ${database.paths.storageDir}`,
127
120
  `Database file: ${database.paths.databaseFile}`,
128
- `Board assets: ${board.action}`,
129
- `Board runtime root: ${board.paths.runtimeRoot}`,
130
121
  `Gitignore: ${gitignoreAction}`,
131
122
  ...buildRecoverySummary(database),
132
123
  ];
@@ -142,11 +133,6 @@ export async function runInit(context: CliContext): Promise<CliResult> {
142
133
  sharedStorageRoot: diagnostics.sharedStorageRoot,
143
134
  storageDir: database.paths.storageDir,
144
135
  databaseFile: database.paths.databaseFile,
145
- board: {
146
- action: board.action,
147
- paths: board.paths,
148
- manifest: board.manifest,
149
- },
150
136
  gitignore: {
151
137
  action: gitignoreAction,
152
138
  path: resolve(database.paths.storageDir, ".gitignore"),
@@ -170,21 +156,6 @@ export async function runInit(context: CliContext): Promise<CliResult> {
170
156
  }
171
157
  }
172
158
 
173
- if (error instanceof BoardInstallError) {
174
- return failResult({
175
- command: "init",
176
- human: error.message,
177
- data: {
178
- code: error.code,
179
- ...error.details,
180
- },
181
- error: {
182
- code: error.code,
183
- message: error.message,
184
- },
185
- });
186
- }
187
-
188
159
  return unexpectedFailureResult(error, {
189
160
  command: "init",
190
161
  human: "Unexpected init command failure",
@@ -32,7 +32,7 @@ const QUICKSTART_TEXT = [
32
32
  " stop and fix the setup before continuing.",
33
33
  "",
34
34
  " Manual bootstrap (step by step):",
35
- " trekoon --toon init",
35
+ " trekoon --toon init # creates .trekoon/ shared storage (no board assets copied)",
36
36
  " trekoon --toon sync status",
37
37
  " trekoon --toon task next",
38
38
  "",
@@ -1,11 +1,19 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
 
3
3
  interface PackageManifest {
4
4
  readonly version?: string;
5
5
  }
6
6
 
7
7
  function readCliVersion(): string {
8
- const packageJsonPath = new URL("../../package.json", import.meta.url);
8
+ const packageJsonPath = [
9
+ new URL("../../package.json", import.meta.url),
10
+ new URL("./package.json", import.meta.url),
11
+ ].find((candidate: URL): boolean => existsSync(candidate));
12
+
13
+ if (packageJsonPath === undefined) {
14
+ throw new Error("package.json is missing from the Trekoon runtime bundle.");
15
+ }
16
+
9
17
  const packageJsonContent: string = readFileSync(packageJsonPath, "utf8");
10
18
  const packageManifest: PackageManifest = JSON.parse(packageJsonContent) as PackageManifest;
11
19
  const version: string | undefined = packageManifest.version;
@@ -4,9 +4,6 @@ import { resolve } from "node:path";
4
4
 
5
5
  export const TREKOON_STORAGE_DIRNAME = ".trekoon";
6
6
  export const TREKOON_DATABASE_FILENAME = "trekoon.db";
7
- export const TREKOON_BOARD_DIRNAME = "board";
8
- export const TREKOON_BOARD_ENTRY_FILENAME = "index.html";
9
- export const TREKOON_BOARD_MANIFEST_FILENAME = "manifest.json";
10
7
 
11
8
  export function resolveLegacyWorktreeStorageDir(worktreeRoot: string): string {
12
9
  return resolve(worktreeRoot, TREKOON_STORAGE_DIRNAME);
@@ -18,18 +15,6 @@ export function resolveLegacyWorktreeDatabaseFile(worktreeRoot: string): string
18
15
 
19
16
  export type StorageMode = "cwd" | "git_common_dir";
20
17
 
21
- export function resolveBoardStorageDir(storageDir: string): string {
22
- return resolve(storageDir, TREKOON_BOARD_DIRNAME);
23
- }
24
-
25
- export function resolveBoardEntryFile(storageDir: string): string {
26
- return resolve(resolveBoardStorageDir(storageDir), TREKOON_BOARD_ENTRY_FILENAME);
27
- }
28
-
29
- export function resolveBoardManifestFile(storageDir: string): string {
30
- return resolve(resolveBoardStorageDir(storageDir), TREKOON_BOARD_MANIFEST_FILENAME);
31
- }
32
-
33
18
  export interface StoragePaths {
34
19
  readonly invocationCwd: string;
35
20
  readonly storageMode: StorageMode;
@@ -38,9 +23,6 @@ export interface StoragePaths {
38
23
  readonly sharedStorageRoot: string;
39
24
  readonly storageDir: string;
40
25
  readonly databaseFile: string;
41
- readonly boardDir: string;
42
- readonly boardEntryFile: string;
43
- readonly boardManifestFile: string;
44
26
  readonly diagnostics: StoragePathDiagnostics;
45
27
  }
46
28
 
@@ -53,9 +35,6 @@ export interface StoragePathIssue {
53
35
  readonly worktreeRoot: string;
54
36
  readonly sharedStorageRoot: string;
55
37
  readonly databaseFile: string;
56
- readonly boardDir: string;
57
- readonly boardEntryFile: string;
58
- readonly boardManifestFile: string;
59
38
  }
60
39
 
61
40
  export interface StoragePathDiagnostics {
@@ -65,9 +44,6 @@ export interface StoragePathDiagnostics {
65
44
  readonly worktreeRoot: string;
66
45
  readonly sharedStorageRoot: string;
67
46
  readonly databaseFile: string;
68
- readonly boardDir: string;
69
- readonly boardEntryFile: string;
70
- readonly boardManifestFile: string;
71
47
  readonly warnings: readonly StoragePathIssue[];
72
48
  readonly errors: readonly StoragePathIssue[];
73
49
  }
@@ -115,9 +91,6 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
115
91
  const sharedStorageRoot: string = repoCommonDir ? realpathSync(resolve(repoCommonDir, "..")) : invocationCwd;
116
92
  const storageDir: string = resolve(sharedStorageRoot, TREKOON_STORAGE_DIRNAME);
117
93
  const databaseFile: string = resolve(storageDir, TREKOON_DATABASE_FILENAME);
118
- const boardDir: string = resolveBoardStorageDir(storageDir);
119
- const boardEntryFile: string = resolveBoardEntryFile(storageDir);
120
- const boardManifestFile: string = resolveBoardManifestFile(storageDir);
121
94
  const warnings: StoragePathIssue[] = [];
122
95
 
123
96
  const createIssue = (code: string, message: string): StoragePathIssue => ({
@@ -129,9 +102,6 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
129
102
  worktreeRoot,
130
103
  sharedStorageRoot,
131
104
  databaseFile,
132
- boardDir,
133
- boardEntryFile,
134
- boardManifestFile,
135
105
  });
136
106
 
137
107
  if (invocationCwd !== worktreeRoot) {
@@ -156,9 +126,6 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
156
126
  worktreeRoot,
157
127
  sharedStorageRoot,
158
128
  databaseFile,
159
- boardDir,
160
- boardEntryFile,
161
- boardManifestFile,
162
129
  warnings,
163
130
  errors: [],
164
131
  };
@@ -171,9 +138,6 @@ export function resolveStoragePaths(workingDirectory: string = process.cwd()): S
171
138
  sharedStorageRoot,
172
139
  storageDir,
173
140
  databaseFile,
174
- boardDir,
175
- boardEntryFile,
176
- boardManifestFile,
177
141
  diagnostics,
178
142
  };
179
143
 
@@ -1,196 +0,0 @@
1
- import {
2
- createHash,
3
- } from "node:crypto";
4
- import {
5
- cpSync,
6
- existsSync,
7
- mkdirSync,
8
- readdirSync,
9
- readFileSync,
10
- rmSync,
11
- statSync,
12
- writeFileSync,
13
- } from "node:fs";
14
- import { dirname, join, relative, resolve } from "node:path";
15
- import { fileURLToPath } from "node:url";
16
-
17
- import { CLI_VERSION } from "../runtime/version";
18
- import {
19
- resolveStoragePaths,
20
- TREKOON_BOARD_ENTRY_FILENAME,
21
- TREKOON_BOARD_MANIFEST_FILENAME,
22
- } from "../storage/path";
23
- import {
24
- BOARD_ASSET_CONTRACT_VERSION,
25
- BOARD_BUNDLED_ASSET_DIRNAME,
26
- BoardInstallError,
27
- type BoardAssetManifest,
28
- type BoardInstallAction,
29
- type BoardInstallResult,
30
- type EnsureBoardInstalledOptions,
31
- } from "./types";
32
-
33
- function resolveBundledBoardAssetRoot(): string {
34
- return fileURLToPath(new URL(`./${BOARD_BUNDLED_ASSET_DIRNAME}`, import.meta.url));
35
- }
36
-
37
- function listRelativeFiles(rootPath: string, currentPath: string = rootPath): string[] {
38
- const entries = readdirSync(currentPath, { withFileTypes: true });
39
- const files: string[] = [];
40
-
41
- for (const entry of entries) {
42
- const entryPath: string = join(currentPath, entry.name);
43
- if (entry.isDirectory()) {
44
- files.push(...listRelativeFiles(rootPath, entryPath));
45
- continue;
46
- }
47
-
48
- if (entry.isFile()) {
49
- files.push(relative(rootPath, entryPath));
50
- }
51
- }
52
-
53
- return files.sort((left, right) => left.localeCompare(right));
54
- }
55
-
56
- interface ReadManifestResult {
57
- readonly manifest: BoardAssetManifest | null;
58
- readonly damaged: boolean;
59
- }
60
-
61
- function readManifest(manifestFile: string): ReadManifestResult {
62
- if (!existsSync(manifestFile)) {
63
- return {
64
- manifest: null,
65
- damaged: false,
66
- };
67
- }
68
-
69
- try {
70
- const rawManifest: string = readFileSync(manifestFile, "utf8");
71
- return {
72
- manifest: JSON.parse(rawManifest) as BoardAssetManifest,
73
- damaged: false,
74
- };
75
- } catch {
76
- return {
77
- manifest: null,
78
- damaged: true,
79
- };
80
- }
81
- }
82
-
83
- function createAssetDigest(sourceRoot: string, files: readonly string[]): string {
84
- const hash = createHash("sha256");
85
-
86
- for (const relativeFile of files) {
87
- hash.update(relativeFile);
88
- hash.update("\0");
89
- hash.update(readFileSync(join(sourceRoot, relativeFile)));
90
- hash.update("\0");
91
- }
92
-
93
- return hash.digest("hex");
94
- }
95
-
96
- function createManifest(sourceRoot: string, assetVersion: string, files: readonly string[]): BoardAssetManifest {
97
- return {
98
- contractVersion: BOARD_ASSET_CONTRACT_VERSION,
99
- assetVersion,
100
- entryFile: TREKOON_BOARD_ENTRY_FILENAME,
101
- files,
102
- assetDigest: createAssetDigest(sourceRoot, files),
103
- };
104
- }
105
-
106
- function installBoardFiles(sourceRoot: string, runtimeRoot: string, manifest: BoardAssetManifest): void {
107
- rmSync(runtimeRoot, { recursive: true, force: true });
108
- mkdirSync(dirname(runtimeRoot), { recursive: true });
109
- cpSync(sourceRoot, runtimeRoot, { recursive: true });
110
- writeFileSync(join(runtimeRoot, TREKOON_BOARD_MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
111
- }
112
-
113
- function determineAction(
114
- runtimeRoot: string,
115
- entryFile: string,
116
- currentManifest: BoardAssetManifest | null,
117
- manifestDamaged: boolean,
118
- nextManifest: BoardAssetManifest,
119
- ): BoardInstallAction {
120
- if (manifestDamaged) {
121
- return "reinstalled";
122
- }
123
-
124
- if (!existsSync(runtimeRoot) || !existsSync(entryFile) || currentManifest === null) {
125
- return currentManifest === null && !existsSync(runtimeRoot) ? "installed" : "reinstalled";
126
- }
127
-
128
- if (
129
- currentManifest.contractVersion !== nextManifest.contractVersion ||
130
- currentManifest.assetVersion !== nextManifest.assetVersion ||
131
- currentManifest.entryFile !== nextManifest.entryFile ||
132
- JSON.stringify(currentManifest.files) !== JSON.stringify(nextManifest.files) ||
133
- currentManifest.assetDigest !== nextManifest.assetDigest
134
- ) {
135
- return "updated";
136
- }
137
-
138
- for (const relativeFile of nextManifest.files) {
139
- if (!existsSync(join(runtimeRoot, relativeFile))) {
140
- return "reinstalled";
141
- }
142
- }
143
-
144
- return "unchanged";
145
- }
146
-
147
- export function ensureBoardInstalled(options: EnsureBoardInstalledOptions = {}): BoardInstallResult {
148
- const paths = resolveStoragePaths(options.workingDirectory);
149
- const sourceRoot: string = resolve(options.bundledAssetRoot ?? resolveBundledBoardAssetRoot());
150
- const runtimeRoot: string = paths.boardDir;
151
- const entryFile: string = paths.boardEntryFile;
152
- const manifestFile: string = paths.boardManifestFile;
153
-
154
- if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) {
155
- throw new BoardInstallError("missing_asset", `Bundled board asset directory not found at ${sourceRoot}`, {
156
- sourceRoot,
157
- });
158
- }
159
-
160
- const sourceFiles: string[] = listRelativeFiles(sourceRoot);
161
- if (!sourceFiles.includes(TREKOON_BOARD_ENTRY_FILENAME)) {
162
- throw new BoardInstallError("missing_asset", `Bundled board entry file not found at ${join(sourceRoot, TREKOON_BOARD_ENTRY_FILENAME)}`, {
163
- sourceRoot,
164
- missingFile: TREKOON_BOARD_ENTRY_FILENAME,
165
- });
166
- }
167
-
168
- const manifest: BoardAssetManifest = createManifest(sourceRoot, options.assetVersion ?? CLI_VERSION, sourceFiles);
169
- const currentManifestResult: ReadManifestResult = readManifest(manifestFile);
170
- const action: BoardInstallAction = determineAction(
171
- runtimeRoot,
172
- entryFile,
173
- currentManifestResult.manifest,
174
- currentManifestResult.damaged,
175
- manifest,
176
- );
177
-
178
- if (action !== "unchanged") {
179
- installBoardFiles(sourceRoot, runtimeRoot, manifest);
180
- }
181
-
182
- return {
183
- action,
184
- paths: {
185
- sourceRoot,
186
- runtimeRoot,
187
- entryFile,
188
- manifestFile,
189
- },
190
- manifest,
191
- };
192
- }
193
-
194
- export function updateBoardInstallation(options: EnsureBoardInstalledOptions = {}): BoardInstallResult {
195
- return ensureBoardInstalled(options);
196
- }