trekoon 0.4.0 → 0.4.2

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 (54) hide show
  1. package/.agents/skills/trekoon/SKILL.md +20 -577
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
  3. package/.agents/skills/trekoon/reference/execution.md +246 -7
  4. package/.agents/skills/trekoon/reference/planning.md +138 -1
  5. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  6. package/.agents/skills/trekoon/reference/sync.md +129 -0
  7. package/README.md +8 -7
  8. package/docs/ai-agents.md +17 -2
  9. package/docs/commands.md +147 -3
  10. package/docs/machine-contracts.md +123 -0
  11. package/docs/quickstart.md +52 -0
  12. package/package.json +1 -1
  13. package/src/board/assets/app.js +49 -16
  14. package/src/board/assets/components/Component.js +22 -8
  15. package/src/board/assets/components/Workspace.js +9 -3
  16. package/src/board/assets/components/helpers.js +5 -1
  17. package/src/board/assets/runtime/delegation.js +8 -0
  18. package/src/board/assets/runtime/focus-trap.js +48 -0
  19. package/src/board/assets/state/actions.js +47 -4
  20. package/src/board/assets/state/api.js +284 -11
  21. package/src/board/assets/state/store.js +87 -11
  22. package/src/board/assets/state/url.js +10 -0
  23. package/src/board/assets/state/utils.js +2 -1
  24. package/src/board/event-bus.ts +72 -0
  25. package/src/board/routes.ts +412 -33
  26. package/src/board/server.ts +77 -8
  27. package/src/board/wal-watcher.ts +302 -0
  28. package/src/commands/board.ts +52 -17
  29. package/src/commands/epic.ts +7 -9
  30. package/src/commands/error-utils.ts +54 -1
  31. package/src/commands/help.ts +69 -4
  32. package/src/commands/migrate.ts +153 -24
  33. package/src/commands/quickstart.ts +7 -0
  34. package/src/commands/subtask.ts +71 -10
  35. package/src/commands/suggest.ts +6 -13
  36. package/src/commands/task.ts +137 -88
  37. package/src/domain/batch-validation.ts +329 -0
  38. package/src/domain/cascade-planner.ts +412 -0
  39. package/src/domain/dependency-rules.ts +15 -0
  40. package/src/domain/mutation-service.ts +828 -192
  41. package/src/domain/search.ts +113 -0
  42. package/src/domain/tracker-domain.ts +150 -680
  43. package/src/domain/types.ts +53 -2
  44. package/src/index.ts +37 -0
  45. package/src/runtime/cli-shell.ts +44 -0
  46. package/src/runtime/daemon.ts +639 -0
  47. package/src/storage/backup.ts +166 -0
  48. package/src/storage/database.ts +261 -4
  49. package/src/storage/migrations.ts +422 -20
  50. package/src/storage/path.ts +8 -0
  51. package/src/storage/schema.ts +5 -1
  52. package/src/sync/event-writes.ts +38 -11
  53. package/src/sync/git-context.ts +226 -8
  54. package/src/sync/service.ts +650 -147
package/docs/ai-agents.md CHANGED
@@ -89,6 +89,10 @@ Typical flow:
89
89
  4. Execute the plan (reads `reference/execution.md` internally)
90
90
  5. Update progress, blockers, and completion state as you go
91
91
 
92
+ > **Experimental — not for routine agent use:** `trekoon serve` and the
93
+ > `--daemon` flag are experimental. Use the default one-shot CLI path for
94
+ > any production or automated agent workflow until daemon mode stabilizes.
95
+
92
96
  ## Default execution loop
93
97
 
94
98
  The core loop: **session, work, task done, repeat**.
@@ -113,14 +117,24 @@ If the session shows you're behind, pull tracker events before claiming work:
113
117
  trekoon --toon sync pull --from main
114
118
  ```
115
119
 
116
- Claim work, assign ownership, then finish or report a block:
120
+ Claim work atomically using SQL compare-and-swap (recommended for parallel
121
+ agents), then finish or report a block:
117
122
 
118
123
  ```bash
119
- trekoon --toon task update <task-id> --status in_progress --owner "agent-1"
124
+ trekoon --toon task claim <task-id> --owner "agent-1"
120
125
  trekoon --toon task done <task-id>
121
126
  trekoon --toon task update <task-id> --append "Blocked by <reason>" --status blocked
122
127
  ```
123
128
 
129
+ `task claim` is the safe primitive for parallel agents. Two concurrent calls on
130
+ the same task return exactly one `claimed: true`. Check `data.claimed` before
131
+ proceeding — if false, another agent won the race.
132
+
133
+ ```bash
134
+ # Fallback: non-atomic claim (single-agent or sequential workflows only)
135
+ trekoon --toon task update <task-id> --status in_progress --owner "agent-1"
136
+ ```
137
+
124
138
  Use `task done` when the task is actually finished. It marks the task complete,
125
139
  auto-transitions through `in_progress` if needed, reports newly unblocked
126
140
  downstream tasks, warns about incomplete subtasks, and returns the next ready
@@ -201,6 +215,7 @@ Use the narrowest command that answers the question:
201
215
  | One task with subtasks | `trekoon --toon task show <task-id> --all` |
202
216
  | One epic tree | `trekoon --toon epic show <epic-id> --all` |
203
217
  | Export epic to Markdown | `trekoon --toon epic export <epic-id>` |
218
+ | Snapshot the DB before manual recovery | `trekoon --toon migrate backup [--retain <n>]` |
204
219
  | Repeated text in one scope | `trekoon --toon epic|task|subtask search ...` |
205
220
 
206
221
  For repeated text changes, use the safe replace loop:
package/docs/commands.md CHANGED
@@ -12,11 +12,11 @@ the quickest way to get started, read [Quickstart](quickstart.md) first.
12
12
  - `trekoon epic <create|expand|list|show|search|replace|update|delete|progress>`
13
13
  - `trekoon session [--epic <epic-id>]`
14
14
  - `trekoon suggest [--epic <epic-id>]`
15
- - `trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete>`
16
- - `trekoon subtask <create|create-many|list|search|replace|update|delete>`
15
+ - `trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete|claim>`
16
+ - `trekoon subtask <create|create-many|list|search|replace|update|delete|claim>`
17
17
  - `trekoon dep <add|add-many|remove|list|reverse>`
18
18
  - `trekoon events prune [--dry-run] [--archive] [--retention-days <n>]`
19
- - `trekoon migrate <status|rollback> [--to-version <n>]`
19
+ - `trekoon migrate <status|rollback|backup> [--to-version <n>] [--retain <n>]`
20
20
  - `trekoon sync status [--from <branch>]`
21
21
  - `trekoon sync pull --from <branch>`
22
22
  - `trekoon sync resolve <conflict-id> --use ours|theirs [--dry-run]`
@@ -26,6 +26,7 @@ the quickest way to get started, read [Quickstart](quickstart.md) first.
26
26
  - `trekoon skills install -g|--global [--editor opencode|claude|pi]`
27
27
  - `trekoon skills update`
28
28
  - `trekoon wipe --yes`
29
+ - `trekoon serve` (experimental)
29
30
 
30
31
  ## Global options
31
32
 
@@ -33,6 +34,7 @@ the quickest way to get started, read [Quickstart](quickstart.md) first.
33
34
  - `--toon` — TOON-encoded output (preferred for agent loops)
34
35
  - `--compact` — strips contract metadata from TOON/JSON envelopes
35
36
  - `--compat <mode>` — explicit machine compatibility behavior
37
+ - `--daemon` — (experimental) route the call through a running `trekoon serve` daemon over a Unix socket; transparently falls back to the in-process CLI when no daemon is reachable. Equivalent to `TREKOON_DAEMON=1`.
36
38
  - `--help` — root and command help
37
39
  - `--version` — CLI version
38
40
 
@@ -73,6 +75,7 @@ Board API endpoints (all require token authentication):
73
75
  | Method | Endpoint | Purpose |
74
76
  | --- | --- | --- |
75
77
  | `GET` | `/api/snapshot` | Full board state (epics, tasks, subtasks, deps, counts) |
78
+ | `GET` | `/api/snapshot/stream` | Server-sent events stream of `snapshot` and `snapshotDelta` events for live updates |
76
79
  | `PATCH` | `/api/epics/{id}` | Update epic title, description, or status |
77
80
  | `PATCH` | `/api/tasks/{id}` | Update task title, description, status, or owner |
78
81
  | `PATCH` | `/api/subtasks/{id}` | Update subtask title, description, status, or owner |
@@ -81,6 +84,19 @@ Board API endpoints (all require token authentication):
81
84
  | `POST` | `/api/dependencies` | Add dependency edge (sourceId, dependsOnId) |
82
85
  | `DELETE` | `/api/dependencies?sourceId=...&dependsOnId=...` | Remove dependency |
83
86
 
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
+ Board mutations from any source — board UI, CLI in another shell, or another
93
+ worktree — propagate to every connected client through the SSE stream. The CLI
94
+ side is driven by a WAL-watcher that diffs the snapshot when
95
+ `.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
+
84
100
  Board commands don't accept command-specific options yet. For tests and local
85
101
  development, `TREKOON_BOARD_ASSET_ROOT` overrides the bundled asset source.
86
102
 
@@ -183,6 +199,41 @@ trekoon subtask update <subtask-id> --owner "agent-2"
183
199
 
184
200
  Also accepted on `PATCH /api/tasks/{id}` and `PATCH /api/subtasks/{id}`.
185
201
 
202
+ ## Task claim
203
+
204
+ ```bash
205
+ trekoon task claim <task-id> --owner <owner>
206
+ trekoon subtask claim <subtask-id> --owner <owner>
207
+ ```
208
+
209
+ Atomically claim a task (or subtask) using a SQL compare-and-swap. Sets
210
+ `status=in_progress` and `owner=<owner>` only when:
211
+
212
+ - The task is in `todo` or `blocked` status, **and**
213
+ - The `owner` column is `NULL` or already equal to `<owner>` (re-entrant claim)
214
+
215
+ Two concurrent `task claim` calls racing on the same task return exactly one
216
+ `claimed: true`. The loser gets `claimed: false` with `currentOwner` and
217
+ `currentStatus` reflecting the winner's write.
218
+
219
+ `--owner` is required.
220
+
221
+ Response envelope:
222
+
223
+ ```text
224
+ ok: true
225
+ command: task.claim | subtask.claim
226
+ data:
227
+ claimed: true | false
228
+ currentOwner: <owner> | null
229
+ currentStatus: in_progress | todo | blocked | done
230
+ task | subtask: { ...full record... } # present only when claimed=true
231
+ ```
232
+
233
+ When `claimed` is false, `task`/`subtask` is absent. `currentOwner` tells you
234
+ who holds the lock. `currentStatus` tells you why the claim failed (already
235
+ `done`, or `in_progress` by another owner).
236
+
186
237
  ## Task done
187
238
 
188
239
  `trekoon task done <task-id>` marks a task complete and returns the next ready
@@ -289,6 +340,99 @@ trekoon --toon sync conflicts show <conflict-id>
289
340
  `list` defaults to `--mode pending`. Use `--mode all` to include resolved
290
341
  conflicts.
291
342
 
343
+ ## Migrate commands
344
+
345
+ ### `migrate status`
346
+
347
+ ```bash
348
+ trekoon --toon migrate status
349
+ ```
350
+
351
+ Reports `currentVersion`, `latestVersion`, and pending migration count.
352
+
353
+ ### `migrate rollback`
354
+
355
+ ```bash
356
+ trekoon --toon migrate rollback [--to-version <n>]
357
+ ```
358
+
359
+ Rolls back schema migrations one version (default) or down to `--to-version`.
360
+ Migrations 0004, 0005, and 0006 are irreversible (ALTER TABLE plus dependency
361
+ data cleanup). Rolling back below those versions errors with code
362
+ `migration_down_unsupported`. Take a backup first; restore by copying the
363
+ backup over `.trekoon/trekoon.db`.
364
+
365
+ ### `migrate backup`
366
+
367
+ ```bash
368
+ trekoon --toon migrate backup
369
+ trekoon --toon migrate backup --retain 5
370
+ ```
371
+
372
+ Snapshots `.trekoon/trekoon.db` to a timestamped sibling file
373
+ (`trekoon.db.backup-<ISO8601>`) using SQLite `VACUUM INTO`. The source database
374
+ is opened read-only, so a backup never mutates live state. Returns
375
+ `backupPath`, `bytes`, `migrationVersion`, and `latestVersion` for machine
376
+ consumers. Backups stay inside `.trekoon/` and are gitignored along with the
377
+ rest of the directory.
378
+
379
+ `--retain <n>` keeps the last `n` timestamped backups; older siblings are
380
+ pruned in the same call. Default is `10`. Backups created within the same
381
+ millisecond produce a deterministic collision error that names the existing
382
+ file so operators can identify it.
383
+
384
+ To restore from a backup: stop any process holding the DB, then copy the
385
+ backup file over `.trekoon/trekoon.db`.
386
+
387
+ ## Daemon (experimental)
388
+
389
+ `trekoon serve` runs Trekoon as a long-lived process listening
390
+ on a Unix-domain socket inside `<storage-root>/.trekoon/daemon.sock`. The
391
+ daemon holds the SQLite connection in memory, so subsequent invocations skip
392
+ Bun startup, module load, migration probes, and database open.
393
+
394
+ Status: **experimental**. Not on by default. The default one-shot CLI behavior
395
+ is unchanged whether or not the daemon is running.
396
+
397
+ Activate the client with one of:
398
+
399
+ ```bash
400
+ TREKOON_DAEMON=1 trekoon session
401
+ trekoon --daemon session
402
+ ```
403
+
404
+ If no daemon is reachable, the client transparently falls back to the in-process
405
+ one-shot path. Calls run against the daemon return envelopes equivalent to the
406
+ in-process call modulo:
407
+
408
+ - The per-request `requestId` and `persistedAt` fields.
409
+ - Environment variables. The client's `process.env` is **not** forwarded over
410
+ the socket; the daemon process uses the environment captured at `trekoon
411
+ serve` startup. If a command's behavior depends on an env var (e.g. an
412
+ override flag), set it before launching the daemon — not on the client side.
413
+
414
+ The daemon request contract is `{argv, cwd}` only — there is no per-call env
415
+ channel. This narrows the secret-leak surface area and keeps the equivalence
416
+ claim crisp.
417
+
418
+ Security:
419
+
420
+ - Socket file mode `0o600`; parent `.trekoon/` directory forced to `0o700`.
421
+ - The umask is tightened to `0o077` around `server.listen()` so the socket
422
+ inode is created with restrictive perms (no TOCTOU window before chmod).
423
+ - Daemon error envelopes return a sanitized message only — no stack trace,
424
+ which would otherwise leak filesystem paths or secret-bearing error text.
425
+ Stacks are still routed to the daemon's local `console.error` for operator
426
+ debugging.
427
+ - Client env is not transmitted over the socket (see equivalence note above).
428
+ - Stale sockets from prior crashes are unlinked at startup.
429
+ - On `Ctrl-C` / `SIGTERM` the socket is unlinked and the cached database
430
+ connection is closed.
431
+
432
+ Performance (see `bench/daemon-session.ts`): daemon median < 10 ms,
433
+ cold one-shot median > 50 ms — roughly a 5–10× latency reduction for
434
+ repeated calls by reusing the held-open SQLite connection.
435
+
292
436
  ## Related docs
293
437
 
294
438
  - [Quickstart](quickstart.md)
@@ -254,6 +254,12 @@ data:
254
254
  Transition details are in `data`, not `error.details`. `error` only has `code`
255
255
  and `message`.
256
256
 
257
+ **Allowed bypass:** `task done` performs an atomic single-transaction direct
258
+ write to `done` from any non-`done` status (`todo`, `blocked`, `in_progress`),
259
+ emitting one `task.updated` event — there is no intermediate `in_progress`
260
+ event when starting from `todo`/`blocked`. This is the only sanctioned
261
+ bypass of the status-machine checker.
262
+
257
263
  ## Epic progress
258
264
 
259
265
  ```bash
@@ -328,6 +334,69 @@ data:
328
334
  pendingConflicts
329
335
  ```
330
336
 
337
+ ## Task claim
338
+
339
+ ```bash
340
+ trekoon --toon task claim <task-id> --owner <owner>
341
+ trekoon --toon subtask claim <subtask-id> --owner <owner>
342
+ ```
343
+
344
+ Atomically claims a task or subtask using SQL compare-and-swap. The single
345
+ UPDATE predicate ensures exactly one concurrent caller gets `claimed: true`.
346
+
347
+ Success (claimed):
348
+
349
+ ```text
350
+ ok: true
351
+ command: task.claim | subtask.claim
352
+ data:
353
+ claimed: true
354
+ currentOwner: <owner>
355
+ currentStatus: in_progress
356
+ task | subtask: { ...full record... }
357
+ ```
358
+
359
+ Not claimed (another owner holds it, or status is done/in_progress by others):
360
+
361
+ ```text
362
+ ok: true
363
+ command: task.claim | subtask.claim
364
+ data:
365
+ claimed: false
366
+ currentOwner: <string> | null
367
+ currentStatus: in_progress | done | todo | blocked
368
+ ```
369
+
370
+ Note: `claimed: false` is a successful response (`ok: true`). The command
371
+ reports the current state, not an error condition. Check `data.claimed` to
372
+ distinguish the two cases.
373
+
374
+ Dependency gating on claim (cr-expert hardening): when the task or subtask is
375
+ in `blocked` or `todo` and has unresolved dependencies, the claim atomically
376
+ fails with `code: dependency_blocked` rather than flipping the row into
377
+ `in_progress`. This mirrors `task done` semantics so neither
378
+ forward-progress transition (`todo|blocked → in_progress` via claim,
379
+ `* → done` via `task done`) can bypass dependency resolution.
380
+
381
+ ```text
382
+ ok: false
383
+ command: task.claim | subtask.claim
384
+ error:
385
+ code: dependency_blocked
386
+ message: "task cannot transition to in_progress while dependencies are unresolved"
387
+ data:
388
+ unresolvedDependencyCount: <number>
389
+ unresolvedDependencyIds: [<id>, ...]
390
+ unresolvedDependencies: [{ id, kind, status }, ...]
391
+ ```
392
+
393
+ The only intentional direct-status-write exception in the codebase remains
394
+ `MutationService.markTaskDoneAtomically`, which bypasses
395
+ `validateStatusTransition` for the `* → done` flip but still goes through
396
+ `assertNoUnresolvedDependenciesForStatusTransition` before issuing the UPDATE.
397
+ No other call site is permitted to write `status` directly without going
398
+ through the public transition checker.
399
+
331
400
  ## Owner field in updates
332
401
 
333
402
  Task and subtask update payloads include `owner`:
@@ -538,6 +607,60 @@ error:
538
607
  message: "Pending conflicts changed before batch resolution could be applied."
539
608
  ```
540
609
 
610
+ ## Error code registry
611
+
612
+ All machine-visible error codes emitted by Trekoon. Every `error.code` value in
613
+ any `ok: false` response will be one of the following strings.
614
+
615
+ | Code | Description |
616
+ |---|---|
617
+ | `already_done` | Entity is already in `done` status; transition is a no-op. |
618
+ | `already_resolved` | Conflict was resolved by another process before this write. |
619
+ | `ambiguous_legacy_state` | Legacy worktree state is ambiguous and cannot be automatically resolved. |
620
+ | `backpressure` | SSE snapshot stream disconnected a slow client whose queued bytes exceeded the hard limit. |
621
+ | `backup_already_exists` | A backup file already exists at the target path; overwrite was not requested. |
622
+ | `backup_database_missing` | Source database file not found when attempting to create a backup. |
623
+ | `backup_failed` | Backup operation failed (I/O or copy error). |
624
+ | `cancelled` | Operation was cancelled by the user (e.g. confirmation prompt rejected). |
625
+ | `confirmation_required` | Human-mode operation requires explicit confirmation before proceeding. |
626
+ | `conflict_set_changed` | Batch conflict set changed between preview and confirmed write. |
627
+ | `daemon_start_failed` | Daemon failed to start (socket bind error or already running). |
628
+ | `database_busy` | SQLite database is locked; retry after a short wait. |
629
+ | `dependency_blocked` | Cascade update blocked because one or more descendants have unresolved dependencies. |
630
+ | `disallowed_field` | Sync resolve attempted to write a field that is not on the allow-list. |
631
+ | `events_failed` | Event log query or export failed. |
632
+ | `install_failed` | Skill installation failed (file copy or permission error). |
633
+ | `internal_error` | Unexpected internal error; inspect server logs for details. |
634
+ | `invalid_args` | Required positional arguments are missing or malformed. |
635
+ | `invalid_dependency` | Dependency edge is invalid (self-loop, wrong entity type, or duplicate). |
636
+ | `invalid_input` | One or more option values failed validation. |
637
+ | `invalid_path` | File path is invalid for the requested operation (e.g. path is a directory). |
638
+ | `invalid_source` | Source entity for a dependency or sync operation does not exist or is of wrong kind. |
639
+ | `invalid_state` | Entity or system is in a state that does not permit the requested operation. |
640
+ | `invalid_subcommand` | Unrecognised subcommand for this command group. |
641
+ | `legacy_import_failed` | Import from a legacy (pre-`.trekoon`) data directory failed. |
642
+ | `migrate_failed` | Database schema migration failed. |
643
+ | `migration_down_unsupported` | Down-migrations are not supported; only forward migrations are allowed. |
644
+ | `missing_asset` | Expected bundled asset (skill file, template) was not found. |
645
+ | `no_matching_conflicts` | `sync resolve --all` filters matched zero pending conflicts. |
646
+ | `not_found` | Requested entity (epic, task, subtask, dependency) does not exist. |
647
+ | `orphaned_external_node` | Dependency graph contains a node that belongs to a different epic. |
648
+ | `outside_repo_target` | Skill install target path is outside the repository root. |
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`. |
651
+ | `row_not_found` | Sync resolve target row no longer exists in the database. |
652
+ | `status_transition_invalid` | Requested status transition is not permitted by the status machine. |
653
+ | `stream_unavailable` | SSE snapshot stream is not available (board not initialised or shutting down). |
654
+ | `sync_failed` | Sync pull or push operation failed. |
655
+ | `tracked_ignored_mismatch` | Worktree is tracked by Trekoon but its path is in `.gitignore`, or vice-versa. |
656
+ | `unauthorized` | Request lacks valid authentication credentials. |
657
+ | `unhandled_command` | Command matched a known group but no case handled the subcommand. |
658
+ | `unknown_command` | Top-level command token is not recognised. |
659
+ | `unknown_option` | An option flag passed to the command is not recognised. |
660
+ | `unsupported_entity_kind` | Sync resolve encountered an entity kind that the writer does not handle. |
661
+ | `update_failed` | Skill update failed (download, copy, or permission error). |
662
+ | `wrong_entity_type` | Operation requires a specific entity kind but a different kind was supplied. |
663
+
541
664
  ## Related docs
542
665
 
543
666
  - [Quickstart](quickstart.md)
@@ -148,6 +148,58 @@ a file extension means "write this file"; no extension means "put the default-
148
148
  named file in this directory". Use `--overwrite` to resave after the plan state
149
149
  changes.
150
150
 
151
+ ## Claim work atomically
152
+
153
+ When multiple agents or lanes work in parallel, use `claim` to avoid races:
154
+
155
+ ```bash
156
+ trekoon task claim <task-id> --owner <owner>
157
+ trekoon subtask claim <subtask-id> --owner <owner>
158
+ ```
159
+
160
+ Both commands use a SQL compare-and-swap: the claim succeeds only when the item
161
+ is `todo` or `blocked` and the owner is `NULL` or already set to `<owner>`.
162
+ The response includes `claimed` (true/false), `currentOwner`, `currentStatus`,
163
+ and the full entity record on success. Two concurrent claims return exactly one
164
+ `claimed=true`.
165
+
166
+ ## Database backup and migration
167
+
168
+ Before any manual migration recovery, snapshot the database:
169
+
170
+ ```bash
171
+ trekoon migrate backup
172
+ trekoon migrate backup --retain 5 # keep last 5 backups (default 10)
173
+ ```
174
+
175
+ This writes a timestamped copy of `.trekoon/trekoon.db` next to the live file.
176
+ To check the current schema version or roll back:
177
+
178
+ ```bash
179
+ trekoon migrate status
180
+ trekoon migrate rollback # one version back
181
+ trekoon migrate rollback --to-version 1
182
+ ```
183
+
184
+ ## Daemon mode (experimental)
185
+
186
+ > **Not for routine agent use.** `trekoon serve` and `--daemon` are
187
+ > experimental; use the default one-shot CLI path in automated workflows.
188
+
189
+ `trekoon serve` starts a foreground daemon on a Unix-domain socket inside
190
+ `.trekoon`. Subsequent calls skip Bun startup, module load, and database
191
+ open — per `bench/daemon-session.ts`: daemon median < 10 ms vs. cold
192
+ one-shot median > 50 ms:
193
+
194
+ ```bash
195
+ trekoon serve # start the daemon (foreground)
196
+ trekoon --daemon session # route a call through the daemon
197
+ TREKOON_DAEMON=1 trekoon session # same, via environment variable
198
+ ```
199
+
200
+ If the socket is missing or unreachable the client falls back silently to the
201
+ normal one-shot path. The socket is 0o600 and `.trekoon` is forced to 0o700.
202
+
151
203
  ## Check progress
152
204
 
153
205
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",
@@ -1,9 +1,10 @@
1
1
  import { createBoardActions } from "./state/actions.js";
2
- import { createApi } from "./state/api.js";
2
+ import { createApi, subscribeSnapshotStream } from "./state/api.js";
3
3
  import { applyTheme, createStore, readThemePreference } from "./state/store.js";
4
4
  import { normalizeSnapshot, normalizeStatus } from "./state/utils.js";
5
5
  import { syncUrlHash } from "./state/url.js";
6
6
  import { createDelegation } from "./runtime/delegation.js";
7
+ import { createOverlayFocusTrap } from "./runtime/focus-trap.js";
7
8
  import { createTopBar } from "./components/TopBar.js";
8
9
  import { createWorkspace } from "./components/Workspace.js";
9
10
  import { createTaskModal } from "./components/TaskModal.js";
@@ -132,15 +133,16 @@ export async function bootLegacyBoard(options = {}) {
132
133
  const runtimeSession = resolveRuntimeSession();
133
134
  if (runtimeSession.shouldScrubAddressBar) scrubTokenFromAddressBar();
134
135
 
135
- // Fetch snapshot
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");
140
- const payload = await response.json();
141
- if (!payload?.ok) throw new Error(payload?.error?.message || "Board request failed");
142
- snapshotPayload = payload?.data?.snapshot ?? {};
136
+ // Always fetch snapshot client-side. The bootstrap script in index.html
137
+ // only carries the auth token; the snapshot is fetched via /api/snapshot
138
+ // so index.html stays small and never carries board data in its HTML body.
139
+ const response = await fetch("/api/snapshot");
140
+ if (!response.ok) {
141
+ throw new Error(`Board snapshot request failed with status ${response.status}`);
143
142
  }
143
+ const payload = await response.json();
144
+ if (!payload?.ok) throw new Error(payload?.error?.message || "Board request failed");
145
+ const snapshotPayload = payload?.data?.snapshot ?? {};
144
146
 
145
147
  const snapshot = normalizeSnapshot(snapshotPayload);
146
148
 
@@ -258,6 +260,12 @@ export async function bootLegacyBoard(options = {}) {
258
260
  overlay.focus({ preventScroll: true });
259
261
  }
260
262
 
263
+ const overlayFocusTrap = createOverlayFocusTrap({
264
+ doc: document,
265
+ onTabKey: (event) => trapOverlayFocus(event),
266
+ onFocusIn: (event) => containOverlayFocus(event),
267
+ });
268
+
261
269
  function syncOverlayEnvironment() {
262
270
  const hadOverlay = activeOverlay instanceof HTMLElement;
263
271
  const nextOverlay = getActiveOverlayElement();
@@ -271,6 +279,10 @@ export async function bootLegacyBoard(options = {}) {
271
279
  lockBackgroundScroll();
272
280
  setBackgroundInert(true);
273
281
  }
282
+ // Attach the document-level focus trap only while an overlay is actually
283
+ // open. This avoids the microtask window where Tab keydowns get swallowed
284
+ // by a stale handler with no overlay to confine to.
285
+ overlayFocusTrap.attach();
274
286
  queueMicrotask(() => focusOverlay(nextOverlay));
275
287
  return;
276
288
  }
@@ -280,6 +292,7 @@ export async function bootLegacyBoard(options = {}) {
280
292
  unlockBackgroundScroll();
281
293
  setBackgroundInert(false);
282
294
  }
295
+ overlayFocusTrap.detach();
283
296
  queueMicrotask(() => restoreOverlayFocus());
284
297
  }
285
298
 
@@ -355,8 +368,9 @@ export async function bootLegacyBoard(options = {}) {
355
368
  return true;
356
369
  }
357
370
 
358
- document.addEventListener("keydown", trapOverlayFocus, true);
359
- document.addEventListener("focusin", containOverlayFocus, true);
371
+ // Focus-trap listeners are attached lazily by syncOverlayEnvironment when an
372
+ // overlay actually opens (and detached when it closes), so plain Tab outside
373
+ // any overlay reaches normal browser flow.
360
374
 
361
375
  // Render cycle
362
376
  function rerender() {
@@ -365,7 +379,9 @@ export async function bootLegacyBoard(options = {}) {
365
379
  const screen = boardState.screen;
366
380
  const selectedTask = boardState.selectedTask;
367
381
  const selectedSubtask = boardState.selectedSubtask;
368
- const currentNav = selectedTask ? "detail" : screen === "tasks" ? "board" : "epics";
382
+ const taskModalOpen = Boolean(boardState.taskModalOpen && selectedTask);
383
+ const subtaskModalOpen = Boolean(boardState.subtaskModalOpen && selectedSubtask);
384
+ const currentNav = taskModalOpen ? "detail" : screen === "tasks" ? "board" : "epics";
369
385
 
370
386
  // Layout toggles
371
387
  const showTasks = screen === "tasks" && boardState.selectedEpic;
@@ -381,11 +397,11 @@ export async function bootLegacyBoard(options = {}) {
381
397
  confirmDialog.update(null);
382
398
  }
383
399
 
384
- if (!showTasks || !selectedTask) {
400
+ if (!showTasks || !taskModalOpen) {
385
401
  taskModal.update(null);
386
402
  }
387
403
 
388
- if (!showTasks || !selectedTask || !selectedSubtask) {
404
+ if (!showTasks || !taskModalOpen || !subtaskModalOpen) {
389
405
  subtaskModal.update(null);
390
406
  }
391
407
 
@@ -428,7 +444,7 @@ export async function bootLegacyBoard(options = {}) {
428
444
  visibleTasks: boardState.visibleTasks,
429
445
  });
430
446
 
431
- taskModal.update(selectedTask ? {
447
+ taskModal.update(taskModalOpen ? {
432
448
  task: selectedTask,
433
449
  epics: store.snapshot.epics,
434
450
  snapshot: store.snapshot,
@@ -442,7 +458,7 @@ export async function bootLegacyBoard(options = {}) {
442
458
  });
443
459
  }
444
460
 
445
- subtaskModal.update(showTasks && selectedTask && selectedSubtask ? {
461
+ subtaskModal.update(showTasks && taskModalOpen && subtaskModalOpen ? {
446
462
  subtask: selectedSubtask,
447
463
  isMutating: store.isMutating,
448
464
  } : null);
@@ -452,6 +468,22 @@ export async function bootLegacyBoard(options = {}) {
452
468
 
453
469
  const api = createApi(model, { sessionToken: runtimeSession.token, rerender });
454
470
 
471
+ // Subscribe to /api/snapshot/stream so external CLI writes (picked up by
472
+ // the WAL watcher) and concurrent-tab mutations are reflected in this
473
+ // board within ~1s without manual refresh. applySnapshotDelta is idempotent
474
+ // so re-receiving deltas already applied via mutation responses is safe.
475
+ // Capture the handle so we can tear it down on page unload (and so tests
476
+ // and future teardown paths can dispose the EventSource).
477
+ const snapshotSubscription = subscribeSnapshotStream(model, {
478
+ sessionToken: runtimeSession.token,
479
+ rerender,
480
+ });
481
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
482
+ window.addEventListener("beforeunload", () => {
483
+ snapshotSubscription.dispose();
484
+ }, { once: true });
485
+ }
486
+
455
487
  // Actions for delegation
456
488
  const actions = createBoardActions({
457
489
  model,
@@ -550,6 +582,7 @@ export async function bootLegacyBoard(options = {}) {
550
582
  addDependency: (src, data) => actions.addDependency(src, data),
551
583
  dropTaskStatus: (id, status) => actions.dropTaskStatus(id, status),
552
584
  getTaskStatus: (id) => actions.getTaskStatus(id),
585
+ setDragFeedback: (feedback) => actions.setDragFeedback(feedback),
553
586
  changeEpicStatus: (epicId, status) => actions.changeEpicStatus(epicId, status),
554
587
  bulkSetStatus: (epicId, status) => actions.bulkSetStatus(epicId, status),
555
588
  toggleEpicStatusFilter: (status) => actions.toggleEpicStatusFilter(status),