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
@@ -0,0 +1,166 @@
1
+ import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+
4
+ import { Database } from "bun:sqlite";
5
+
6
+ import { DomainError } from "../domain/types";
7
+ import { LATEST_MIGRATION_VERSION, readCurrentMigrationVersionReadOnly } from "./migrations";
8
+ import { resolveStoragePaths } from "./path";
9
+
10
+ export interface MigrateBackupResult {
11
+ readonly backupPath: string;
12
+ readonly bytes: number;
13
+ readonly migrationVersion: number;
14
+ readonly latestVersion: number;
15
+ readonly timestamp: string;
16
+ readonly retainedCount: number;
17
+ readonly prunedPaths: readonly string[];
18
+ }
19
+
20
+ /** Default retention count when --retain is not provided. */
21
+ export const DEFAULT_BACKUP_RETENTION = 10;
22
+
23
+ /** Filename prefix shared by every backup snapshot. */
24
+ const BACKUP_FILENAME_PREFIX = "trekoon.db.backup-";
25
+
26
+ function isoTimestampForFilename(now: Date): string {
27
+ // ISO 8601 with colons and dots replaced for filesystem-safe use across
28
+ // macOS/Linux/Windows. Resolution is millisecond-precise so two backups in
29
+ // the same second still produce distinct filenames.
30
+ // Example input: 2026-05-02T13:45:30.123Z -> 2026-05-02T13-45-30-123Z
31
+ return now.toISOString().replace(/[:.]/gu, "-");
32
+ }
33
+
34
+ function quoteForVacuumInto(filePath: string): string {
35
+ // SQLite path literal: wrap in single quotes and escape embedded single quotes by doubling.
36
+ return `'${filePath.replace(/'/gu, "''")}'`;
37
+ }
38
+
39
+ interface CreateMigrationBackupOptions {
40
+ readonly cwd: string;
41
+ readonly now?: Date;
42
+ /**
43
+ * Maximum number of timestamped backup siblings to retain (including the
44
+ * one being created). Older snapshots are pruned. Defaults to
45
+ * {@link DEFAULT_BACKUP_RETENTION}. Pass `Infinity` or any non-positive
46
+ * number to disable pruning.
47
+ */
48
+ readonly retain?: number;
49
+ }
50
+
51
+ interface ListedBackup {
52
+ readonly filename: string;
53
+ readonly fullPath: string;
54
+ }
55
+
56
+ function listExistingBackups(storageDir: string): ListedBackup[] {
57
+ if (!existsSync(storageDir)) {
58
+ return [];
59
+ }
60
+
61
+ return readdirSync(storageDir)
62
+ .filter((entry) => entry.startsWith(BACKUP_FILENAME_PREFIX))
63
+ .map((entry) => ({ filename: entry, fullPath: resolve(storageDir, entry) }));
64
+ }
65
+
66
+ function pruneOlderBackups(storageDir: string, keepCount: number): string[] {
67
+ if (!Number.isFinite(keepCount) || keepCount <= 0) {
68
+ return [];
69
+ }
70
+
71
+ const existing = listExistingBackups(storageDir);
72
+ if (existing.length <= keepCount) {
73
+ return [];
74
+ }
75
+
76
+ // Filename suffix is a millisecond-precise ISO timestamp, so a lexicographic
77
+ // sort is equivalent to chronological order. Most-recent first.
78
+ const sorted = [...existing].sort((left, right) => {
79
+ if (left.filename < right.filename) {
80
+ return 1;
81
+ }
82
+ if (left.filename > right.filename) {
83
+ return -1;
84
+ }
85
+ return 0;
86
+ });
87
+
88
+ const toPrune = sorted.slice(keepCount);
89
+ const pruned: string[] = [];
90
+
91
+ for (const candidate of toPrune) {
92
+ try {
93
+ unlinkSync(candidate.fullPath);
94
+ pruned.push(candidate.fullPath);
95
+ } catch {
96
+ // Best-effort prune: a transient unlink failure (concurrent backup,
97
+ // permissions, etc.) must not abort the surrounding backup operation.
98
+ }
99
+ }
100
+
101
+ return pruned;
102
+ }
103
+
104
+ export function createMigrationBackup(options: CreateMigrationBackupOptions): MigrateBackupResult {
105
+ const cwd: string = options.cwd;
106
+ const now: Date = options.now ?? new Date();
107
+ const retainRaw: number = options.retain ?? DEFAULT_BACKUP_RETENTION;
108
+ const retainCount: number =
109
+ Number.isFinite(retainRaw) && retainRaw > 0 ? Math.floor(retainRaw) : Number.POSITIVE_INFINITY;
110
+ const storagePaths = resolveStoragePaths(cwd);
111
+ const databaseFile: string = storagePaths.databaseFile;
112
+
113
+ if (!existsSync(databaseFile)) {
114
+ throw new DomainError({
115
+ code: "backup_database_missing",
116
+ message: `Cannot back up Trekoon database: ${databaseFile} does not exist. Run 'trekoon init' first.`,
117
+ details: { databaseFile },
118
+ });
119
+ }
120
+
121
+ const timestamp: string = isoTimestampForFilename(now);
122
+ const storageDir: string = dirname(databaseFile);
123
+ const backupFilename = `${BACKUP_FILENAME_PREFIX}${timestamp}`;
124
+ const backupPath: string = resolve(storageDir, backupFilename);
125
+
126
+ if (existsSync(backupPath)) {
127
+ throw new DomainError({
128
+ code: "backup_already_exists",
129
+ message:
130
+ `Backup already exists at ${backupPath}. ` +
131
+ `Backup filenames are millisecond-precise; two backups can only collide ` +
132
+ `when the same explicit timestamp is reused. Wait at least one millisecond ` +
133
+ `between backups or pass a distinct now/timestamp.`,
134
+ details: { backupPath },
135
+ });
136
+ }
137
+
138
+ // VACUUM INTO writes a fully-consistent snapshot at a single transaction
139
+ // boundary, including any uncommitted WAL state once the read transaction
140
+ // is taken. This is the SQLite-recommended way to atomically clone a DB.
141
+ // Open read-only so we never mutate the live DB while snapshotting.
142
+ const sourceDb = new Database(databaseFile, { readonly: true });
143
+ let migrationVersion = 0;
144
+ const latestVersion: number = LATEST_MIGRATION_VERSION;
145
+ try {
146
+ migrationVersion = readCurrentMigrationVersionReadOnly(sourceDb);
147
+ sourceDb.exec(`VACUUM INTO ${quoteForVacuumInto(backupPath)};`);
148
+ } finally {
149
+ sourceDb.close(false);
150
+ }
151
+
152
+ const stats = statSync(backupPath);
153
+
154
+ const prunedPaths: readonly string[] = pruneOlderBackups(storageDir, retainCount);
155
+ const retainedCount: number = listExistingBackups(storageDir).length;
156
+
157
+ return {
158
+ backupPath,
159
+ bytes: stats.size,
160
+ migrationVersion,
161
+ latestVersion,
162
+ timestamp,
163
+ retainedCount,
164
+ prunedPaths,
165
+ };
166
+ }
@@ -1,16 +1,55 @@
1
- import { mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync } from "node:fs";
2
2
 
3
3
  import { Database } from "bun:sqlite";
4
4
 
5
5
  import { DomainError } from "../domain/types";
6
- import { migrateDatabase } from "./migrations";
7
- import { resolveStoragePaths, type StoragePaths } from "./path";
6
+ import { LATEST_MIGRATION_VERSION, migrateDatabase, readCurrentMigrationVersionReadOnly } from "./migrations";
7
+ import { resolveLegacyWorktreeDatabaseFile, resolveStoragePaths, type StoragePaths } from "./path";
8
8
  import {
9
9
  inspectWorktreeDatabaseState,
10
10
  recoverWorktreeDatabaseState,
11
11
  type WorktreeRecoveryDiagnostics,
12
12
  } from "./worktree-recovery";
13
13
 
14
+ /**
15
+ * Re-inspect worktree state on a daemon-mode cache hit so out-of-band changes
16
+ * to the worktree (e.g. an operator restoring a legacy `.trekoon/trekoon.db`
17
+ * after the daemon started, or a new tracked-vs-ignored conflict) surface to
18
+ * the caller through `diagnostics.recoveryRequired` instead of being masked
19
+ * by the cached handle's stale snapshot. Returns NO_LEGACY_RECOVERY when the
20
+ * fast-path no-legacy-DB shortcut applies and falls back to the
21
+ * DomainError-aware capture used by `resolveStorageResolutionDiagnostics`
22
+ * when inspection itself raises (e.g. tracked_ignored_mismatch).
23
+ */
24
+ function reinspectRecoveryForCacheHit(paths: StoragePaths): WorktreeRecoveryDiagnostics {
25
+ const legacyDbFile: string = resolveLegacyWorktreeDatabaseFile(paths.worktreeRoot);
26
+ if (legacyDbFile !== paths.databaseFile && !existsSync(legacyDbFile)) {
27
+ return NO_LEGACY_RECOVERY;
28
+ }
29
+
30
+ try {
31
+ return inspectWorktreeDatabaseState(paths);
32
+ } catch (error) {
33
+ if (!(error instanceof DomainError)) {
34
+ throw error;
35
+ }
36
+ const details: Record<string, unknown> = error.details ?? {};
37
+ return {
38
+ status: (details.status as WorktreeRecoveryDiagnostics["status"] | undefined) ?? "no_legacy_state",
39
+ legacyDatabaseFiles: Array.isArray(details.legacyDatabaseFiles)
40
+ ? (details.legacyDatabaseFiles as string[])
41
+ : [],
42
+ backupFiles: Array.isArray(details.backupFiles) ? (details.backupFiles as string[]) : [],
43
+ trackedStorageFiles: Array.isArray(details.trackedStorageFiles)
44
+ ? (details.trackedStorageFiles as string[])
45
+ : [],
46
+ autoMigrated: details.autoMigrated === true,
47
+ importedFrom: typeof details.importedFrom === "string" ? details.importedFrom : null,
48
+ operatorAction: typeof details.operatorAction === "string" ? details.operatorAction : error.message,
49
+ };
50
+ }
51
+ }
52
+
14
53
  export interface StorageResolutionDiagnostics {
15
54
  readonly invocationCwd: string;
16
55
  readonly storageMode: StoragePaths["storageMode"];
@@ -140,12 +179,208 @@ export function writeTransaction<T>(db: Database, fn: (db: Database) => T): T {
140
179
  }
141
180
  }
142
181
 
182
+ /** Sentinel recovery result for the common case: no legacy worktree DB present. */
183
+ const NO_LEGACY_RECOVERY: WorktreeRecoveryDiagnostics = {
184
+ status: "no_legacy_state",
185
+ legacyDatabaseFiles: [],
186
+ backupFiles: [],
187
+ trackedStorageFiles: [],
188
+ autoMigrated: false,
189
+ importedFrom: null,
190
+ operatorAction: "No legacy worktree-local database detected.",
191
+ };
192
+
193
+ /**
194
+ * Process-level cache of opened TrekoonDatabase handles. Enabled only when
195
+ * `TREKOON_DAEMON_INPROCESS=1` is set (the daemon spike sets this on startup).
196
+ * In normal one-shot CLI invocations this map stays empty and the cache layer
197
+ * is bypassed entirely, so default behavior is unchanged.
198
+ *
199
+ * Cached handles override `close()` so callers that follow the
200
+ * `try { ... } finally { db?.close(); }` pattern do not actually tear down
201
+ * the shared connection — instead, `close()` decrements an in-use refcount.
202
+ * The daemon shutdown path calls `closeCachedDatabases()`.
203
+ *
204
+ * The cache is bounded LRU (insertion-order on Map; touched on access) so a
205
+ * long-running daemon serving many distinct cwds does not accumulate open
206
+ * SQLite connections / FDs without bound. Eviction closes the underlying
207
+ * database after a passive WAL checkpoint.
208
+ *
209
+ * In-use protection: each `openTrekoonDatabase` call increments the entry's
210
+ * refcount; the matching `close()` decrements it. Eviction skips entries
211
+ * whose refcount is > 0 — closing them mid-query would surface as
212
+ * SQLITE_MISUSE on the in-flight handler. If every cached entry is in use
213
+ * when a new cwd is opened, we permit temporary growth past the cap and
214
+ * emit a single warning; the next eviction-check after a `close()` will
215
+ * reduce the cache back under the cap.
216
+ */
217
+ const CACHED_DATABASES_CAPACITY = 16;
218
+
219
+ interface CachedEntry {
220
+ readonly handle: TrekoonDatabase;
221
+ refcount: number;
222
+ }
223
+
224
+ const cachedDatabases: Map<string, CachedEntry> = new Map();
225
+ let warnedOnTransientOvergrowth = false;
226
+
227
+ function isDaemonInProcessCacheEnabled(): boolean {
228
+ return process.env.TREKOON_DAEMON_INPROCESS === "1";
229
+ }
230
+
231
+ function closeCachedHandle(handle: TrekoonDatabase): void {
232
+ try {
233
+ handle.db.exec("PRAGMA wal_checkpoint(PASSIVE);");
234
+ } catch {
235
+ /* best effort */
236
+ }
237
+ try {
238
+ handle.db.close(false);
239
+ } catch {
240
+ /* best effort */
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Evict the least-recently-used handle when the cache is at capacity. Map
246
+ * iteration order in JS is insertion order; we re-insert on access (`touch`)
247
+ * to model LRU semantics. Entries whose refcount is > 0 are SKIPPED — closing
248
+ * an in-use handle would surface as SQLITE_MISUSE for the caller currently
249
+ * borrowing it. A subsequent `releaseCachedDatabase` after the caller's
250
+ * `close()` will re-run eviction so transient over-cap growth is bounded by
251
+ * the number of concurrently-borrowed entries, which is in practice tiny.
252
+ */
253
+ function evictLruIfNeeded(): void {
254
+ while (cachedDatabases.size >= CACHED_DATABASES_CAPACITY) {
255
+ let evicted = false;
256
+ for (const [key, entry] of cachedDatabases) {
257
+ if (entry.refcount > 0) {
258
+ continue;
259
+ }
260
+ cachedDatabases.delete(key);
261
+ closeCachedHandle(entry.handle);
262
+ evicted = true;
263
+ break;
264
+ }
265
+
266
+ if (!evicted) {
267
+ // Every cached entry is currently borrowed. Allow temporary growth
268
+ // past the cap rather than closing an in-use handle. Surface a single
269
+ // warning so an operator can spot pathological concurrency.
270
+ if (!warnedOnTransientOvergrowth) {
271
+ warnedOnTransientOvergrowth = true;
272
+ // eslint-disable-next-line no-console
273
+ console.warn(
274
+ `[trekoon daemon] all ${CACHED_DATABASES_CAPACITY} cached database handles are in use; temporarily growing cache past the cap`,
275
+ );
276
+ }
277
+ return;
278
+ }
279
+ }
280
+ }
281
+
282
+ function touchCachedDatabase(key: string, entry: CachedEntry): void {
283
+ // Re-insert to move to the most-recently-used (tail) end of insertion order.
284
+ cachedDatabases.delete(key);
285
+ cachedDatabases.set(key, entry);
286
+ }
287
+
288
+ /**
289
+ * Decrement the refcount for a cached entry. Called from the cached handle's
290
+ * `close()` method. After a release, re-run eviction in case the cache had
291
+ * grown past the cap because every entry was borrowed.
292
+ */
293
+ function releaseCachedDatabase(key: string): void {
294
+ const entry = cachedDatabases.get(key);
295
+ if (!entry) {
296
+ return;
297
+ }
298
+ if (entry.refcount > 0) {
299
+ entry.refcount -= 1;
300
+ }
301
+ if (cachedDatabases.size > CACHED_DATABASES_CAPACITY) {
302
+ evictLruIfNeeded();
303
+ }
304
+ }
305
+
306
+ export function closeCachedDatabases(): void {
307
+ for (const entry of cachedDatabases.values()) {
308
+ closeCachedHandle(entry.handle);
309
+ }
310
+ cachedDatabases.clear();
311
+ warnedOnTransientOvergrowth = false;
312
+ }
313
+
314
+ /**
315
+ * Test-only: report the current size of the daemon-mode database cache.
316
+ * Production code never inspects this — used by `tests/runtime/cache-bound`.
317
+ */
318
+ export function cachedDatabasesSize(): number {
319
+ return cachedDatabases.size;
320
+ }
321
+
143
322
  export function openTrekoonDatabase(
144
323
  workingDirectory: string = process.cwd(),
145
324
  options: OpenTrekoonDatabaseOptions = {},
146
325
  ): TrekoonDatabase {
147
326
  const paths: StoragePaths = resolveStoragePaths(workingDirectory);
148
- const recovery: WorktreeRecoveryDiagnostics = recoverWorktreeDatabaseState(paths);
327
+
328
+ // Daemon-mode reuse: when running inside `trekoon serve`, return a cached
329
+ // connection so each request avoids the migration probe and database open.
330
+ if (isDaemonInProcessCacheEnabled()) {
331
+ const cachedEntry = cachedDatabases.get(paths.databaseFile);
332
+ if (cachedEntry) {
333
+ // Honor autoMigrate on the cached-handle path: a previous request that
334
+ // opened this DB with `{autoMigrate: false}` (e.g. migrate-status) may
335
+ // have left the schema below LATEST_MIGRATION_VERSION. The next request
336
+ // that asks for autoMigrate (the default) must still get a migrated DB.
337
+ if (
338
+ (options.autoMigrate ?? true)
339
+ && readCurrentMigrationVersionReadOnly(cachedEntry.handle.db) < LATEST_MIGRATION_VERSION
340
+ ) {
341
+ migrateDatabase(cachedEntry.handle.db);
342
+ }
343
+
344
+ // Re-inspect worktree state on every cache hit so out-of-band changes
345
+ // (legacy DB restored after daemon start, fresh tracked/ignored
346
+ // mismatch, etc.) surface to callers through fresh diagnostics. The
347
+ // cached `handle.diagnostics` is a frozen snapshot from the original
348
+ // open and would otherwise mask a freshly-required recovery — leading
349
+ // `session` and friends to silently proceed against a stale state.
350
+ const refreshedRecovery: WorktreeRecoveryDiagnostics = reinspectRecoveryForCacheHit(paths);
351
+ const refreshedDiagnostics: StorageResolutionDiagnostics =
352
+ buildStorageResolutionDiagnostics(paths, refreshedRecovery);
353
+
354
+ // Refresh LRU position on access and increment the in-use refcount so
355
+ // `evictLruIfNeeded` cannot close this entry under the borrower.
356
+ cachedEntry.refcount += 1;
357
+ touchCachedDatabase(paths.databaseFile, cachedEntry);
358
+
359
+ const cacheKey: string = paths.databaseFile;
360
+ // Return a per-request handle wrapper that exposes the refreshed
361
+ // diagnostics while sharing the underlying connection. The wrapper's
362
+ // close() releases the same cache key the cached entry's close()
363
+ // does, so refcount accounting stays consistent regardless of which
364
+ // handle the caller holds.
365
+ return {
366
+ db: cachedEntry.handle.db,
367
+ paths: cachedEntry.handle.paths,
368
+ diagnostics: refreshedDiagnostics,
369
+ close(): void {
370
+ releaseCachedDatabase(cacheKey);
371
+ },
372
+ };
373
+ }
374
+ }
375
+
376
+ // Fast path: if no legacy .trekoon/trekoon.db exists in the current worktree,
377
+ // skip the git-heavy recoverWorktreeDatabaseState entirely.
378
+ const legacyDbFile: string = resolveLegacyWorktreeDatabaseFile(paths.worktreeRoot);
379
+ const recovery: WorktreeRecoveryDiagnostics =
380
+ legacyDbFile !== paths.databaseFile && !existsSync(legacyDbFile)
381
+ ? NO_LEGACY_RECOVERY
382
+ : recoverWorktreeDatabaseState(paths);
383
+
149
384
  const diagnostics: StorageResolutionDiagnostics = buildStorageResolutionDiagnostics(paths, recovery);
150
385
 
151
386
  mkdirSync(paths.storageDir, { recursive: true });
@@ -160,6 +395,28 @@ export function openTrekoonDatabase(
160
395
  migrateDatabase(db);
161
396
  }
162
397
 
398
+ if (isDaemonInProcessCacheEnabled()) {
399
+ const cacheKey: string = paths.databaseFile;
400
+ const cachedHandle: TrekoonDatabase = {
401
+ db,
402
+ paths,
403
+ diagnostics,
404
+ close(): void {
405
+ // The daemon owns the lifetime (freed via closeCachedDatabases()), but
406
+ // we still need to track in-use refcount so eviction does not close a
407
+ // handle that an in-flight request is still using.
408
+ releaseCachedDatabase(cacheKey);
409
+ },
410
+ };
411
+ // Bound the cache before insertion so a daemon serving many distinct
412
+ // cwds doesn't accumulate open FDs without limit. Eviction will skip
413
+ // entries that other requests are currently borrowing.
414
+ evictLruIfNeeded();
415
+ // Initial refcount = 1 reflects the borrow taken by THIS caller.
416
+ cachedDatabases.set(cacheKey, { handle: cachedHandle, refcount: 1 });
417
+ return cachedHandle;
418
+ }
419
+
163
420
  return {
164
421
  db,
165
422
  paths,