trekoon 0.4.1 → 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.
- package/.agents/skills/trekoon/SKILL.md +20 -577
- package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
- package/.agents/skills/trekoon/reference/execution.md +246 -7
- package/.agents/skills/trekoon/reference/planning.md +138 -1
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +129 -0
- package/README.md +8 -1
- package/docs/ai-agents.md +17 -2
- package/docs/commands.md +147 -3
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +52 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +45 -13
- package/src/board/assets/components/Component.js +22 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +4 -0
- package/src/board/assets/runtime/delegation.js +8 -0
- package/src/board/assets/runtime/focus-trap.js +48 -0
- package/src/board/assets/state/actions.js +42 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +79 -11
- package/src/board/assets/state/url.js +10 -0
- package/src/board/assets/state/utils.js +2 -1
- package/src/board/event-bus.ts +72 -0
- package/src/board/routes.ts +412 -33
- package/src/board/server.ts +77 -8
- package/src/board/wal-watcher.ts +302 -0
- package/src/commands/board.ts +52 -17
- package/src/commands/epic.ts +7 -9
- package/src/commands/error-utils.ts +54 -1
- package/src/commands/help.ts +69 -4
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/subtask.ts +71 -10
- package/src/commands/suggest.ts +6 -13
- package/src/commands/task.ts +137 -88
- package/src/domain/batch-validation.ts +329 -0
- package/src/domain/cascade-planner.ts +412 -0
- package/src/domain/dependency-rules.ts +15 -0
- package/src/domain/mutation-service.ts +828 -192
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +150 -680
- package/src/domain/types.ts +53 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +639 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +261 -4
- package/src/storage/migrations.ts +422 -20
- package/src/storage/path.ts +8 -0
- package/src/storage/schema.ts +5 -1
- package/src/sync/event-writes.ts +38 -11
- package/src/sync/git-context.ts +226 -8
- 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
|
|
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
|
|
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)
|
package/docs/quickstart.md
CHANGED
|
@@ -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
package/src/board/assets/app.js
CHANGED
|
@@ -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
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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() {
|
|
@@ -366,6 +380,7 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
366
380
|
const selectedTask = boardState.selectedTask;
|
|
367
381
|
const selectedSubtask = boardState.selectedSubtask;
|
|
368
382
|
const taskModalOpen = Boolean(boardState.taskModalOpen && selectedTask);
|
|
383
|
+
const subtaskModalOpen = Boolean(boardState.subtaskModalOpen && selectedSubtask);
|
|
369
384
|
const currentNav = taskModalOpen ? "detail" : screen === "tasks" ? "board" : "epics";
|
|
370
385
|
|
|
371
386
|
// Layout toggles
|
|
@@ -386,7 +401,7 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
386
401
|
taskModal.update(null);
|
|
387
402
|
}
|
|
388
403
|
|
|
389
|
-
if (!showTasks || !taskModalOpen || !
|
|
404
|
+
if (!showTasks || !taskModalOpen || !subtaskModalOpen) {
|
|
390
405
|
subtaskModal.update(null);
|
|
391
406
|
}
|
|
392
407
|
|
|
@@ -443,7 +458,7 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
443
458
|
});
|
|
444
459
|
}
|
|
445
460
|
|
|
446
|
-
subtaskModal.update(showTasks && taskModalOpen &&
|
|
461
|
+
subtaskModal.update(showTasks && taskModalOpen && subtaskModalOpen ? {
|
|
447
462
|
subtask: selectedSubtask,
|
|
448
463
|
isMutating: store.isMutating,
|
|
449
464
|
} : null);
|
|
@@ -453,6 +468,22 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
453
468
|
|
|
454
469
|
const api = createApi(model, { sessionToken: runtimeSession.token, rerender });
|
|
455
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
|
+
|
|
456
487
|
// Actions for delegation
|
|
457
488
|
const actions = createBoardActions({
|
|
458
489
|
model,
|
|
@@ -551,6 +582,7 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
551
582
|
addDependency: (src, data) => actions.addDependency(src, data),
|
|
552
583
|
dropTaskStatus: (id, status) => actions.dropTaskStatus(id, status),
|
|
553
584
|
getTaskStatus: (id) => actions.getTaskStatus(id),
|
|
585
|
+
setDragFeedback: (feedback) => actions.setDragFeedback(feedback),
|
|
554
586
|
changeEpicStatus: (epicId, status) => actions.changeEpicStatus(epicId, status),
|
|
555
587
|
bulkSetStatus: (epicId, status) => actions.bulkSetStatus(epicId, status),
|
|
556
588
|
toggleEpicStatusFilter: (status) => actions.toggleEpicStatusFilter(status),
|
|
@@ -80,11 +80,18 @@ function getNamespacedFormIdentity(form) {
|
|
|
80
80
|
return "anonymous-form";
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
function getManagedControls(root) {
|
|
83
|
+
function getManagedControls(root, cache) {
|
|
84
|
+
if (cache) {
|
|
85
|
+
const cached = cache.get(root);
|
|
86
|
+
if (cached) return cached;
|
|
87
|
+
const controls = Array.from(root.querySelectorAll("input, textarea, select"));
|
|
88
|
+
cache.set(root, controls);
|
|
89
|
+
return controls;
|
|
90
|
+
}
|
|
84
91
|
return Array.from(root.querySelectorAll("input, textarea, select"));
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
function getControlIdentity(el, form) {
|
|
94
|
+
function getControlIdentity(el, form, cache) {
|
|
88
95
|
const controlKey = el.getAttribute("data-control-id");
|
|
89
96
|
if (controlKey) {
|
|
90
97
|
return `control:${controlKey}`;
|
|
@@ -98,12 +105,12 @@ function getControlIdentity(el, form) {
|
|
|
98
105
|
if (name) {
|
|
99
106
|
const tagName = el.tagName.toLowerCase();
|
|
100
107
|
const type = tagName === "input" ? (el.getAttribute("type") ?? "text") : tagName;
|
|
101
|
-
const peers = getManagedControls(form).filter((candidate) => candidate.getAttribute("name") === name);
|
|
108
|
+
const peers = getManagedControls(form, cache).filter((candidate) => candidate.getAttribute("name") === name);
|
|
102
109
|
const index = peers.indexOf(el);
|
|
103
110
|
return `name:${name}:${type}:${index}`;
|
|
104
111
|
}
|
|
105
112
|
|
|
106
|
-
const index = getManagedControls(form).indexOf(el);
|
|
113
|
+
const index = getManagedControls(form, cache).indexOf(el);
|
|
107
114
|
return `index:${index}:${el.tagName.toLowerCase()}`;
|
|
108
115
|
}
|
|
109
116
|
|
|
@@ -143,7 +150,11 @@ function restoreSelection(el, selectionStart, selectionEnd) {
|
|
|
143
150
|
*/
|
|
144
151
|
export function preserveFormState(container, writeFn, options = {}) {
|
|
145
152
|
const resetFormIds = new Set(options.resetFormIds ?? []);
|
|
146
|
-
|
|
153
|
+
|
|
154
|
+
// Per-form cache for getManagedControls: avoids the O(n^2) re-query that
|
|
155
|
+
// occurs when many controls share the same form root.
|
|
156
|
+
const captureCache = new Map();
|
|
157
|
+
const inputs = getManagedControls(container, captureCache);
|
|
147
158
|
|
|
148
159
|
const activeElement = document.activeElement;
|
|
149
160
|
let focusedIdentity = null;
|
|
@@ -151,7 +162,7 @@ export function preserveFormState(container, writeFn, options = {}) {
|
|
|
151
162
|
const savedStates = inputs.map((el) => {
|
|
152
163
|
const form = getFormRoot(el);
|
|
153
164
|
const formId = getNamespacedFormIdentity(form);
|
|
154
|
-
const controlId = form ? getControlIdentity(el, form) : null;
|
|
165
|
+
const controlId = form ? getControlIdentity(el, form, captureCache) : null;
|
|
155
166
|
const identity = controlId ? { formId, controlId } : null;
|
|
156
167
|
|
|
157
168
|
if (activeElement === el) {
|
|
@@ -177,13 +188,16 @@ export function preserveFormState(container, writeFn, options = {}) {
|
|
|
177
188
|
]),
|
|
178
189
|
);
|
|
179
190
|
|
|
191
|
+
// Fresh cache for the restore pass (DOM was replaced by writeFn).
|
|
192
|
+
const restoreCache = new Map();
|
|
193
|
+
|
|
180
194
|
for (const state of savedStates) {
|
|
181
195
|
const { formId, controlId } = state.identity;
|
|
182
196
|
if (resetFormIds.has(formId)) {
|
|
183
197
|
continue;
|
|
184
198
|
}
|
|
185
199
|
const form = formsByIdentity.get(formId) ?? container;
|
|
186
|
-
const restored = getManagedControls(form).find((control) => getControlIdentity(control, form) === controlId);
|
|
200
|
+
const restored = getManagedControls(form, restoreCache).find((control) => getControlIdentity(control, form, restoreCache) === controlId);
|
|
187
201
|
if (restored && restored.value !== state.value) {
|
|
188
202
|
restored.value = state.value;
|
|
189
203
|
}
|
|
@@ -199,7 +213,7 @@ export function preserveFormState(container, writeFn, options = {}) {
|
|
|
199
213
|
return;
|
|
200
214
|
}
|
|
201
215
|
const form = formsByIdentity.get(formId) ?? container;
|
|
202
|
-
const restored = getManagedControls(form).find((control) => getControlIdentity(control, form) === controlId);
|
|
216
|
+
const restored = getManagedControls(form, restoreCache).find((control) => getControlIdentity(control, form, restoreCache) === controlId);
|
|
203
217
|
if (restored) {
|
|
204
218
|
restored.focus({ preventScroll: true });
|
|
205
219
|
const focusedState = savedStates.find((state) => state.identity?.formId === formId && state.identity?.controlId === controlId);
|