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.
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 -1
  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 +45 -13
  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 +4 -0
  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 +42 -4
  20. package/src/board/assets/state/api.js +284 -11
  21. package/src/board/assets/state/store.js +79 -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
@@ -116,9 +116,19 @@ export function orderEpicsNewestFirst(epics) {
116
116
 
117
117
  /** Recently-done epics stay visible for 24h even when the "done" filter is off. */
118
118
  const DONE_GRACE_PERIOD_MS = 86400000;
119
+ /**
120
+ * Bucket Date.now() into hour-aligned chunks so the memoized epic selector
121
+ * re-evaluates the 24h grace cutoff at most once per hour on a long-open page.
122
+ */
123
+ const GRACE_BUCKET_MS = 3600000;
119
124
 
120
125
  const selectVisibleEpics = createSelector(
121
- (s) => [s.snapshot?.epics, s.searchQuery, s.epicStatusFilter],
126
+ (s) => [
127
+ s.snapshot?.epics,
128
+ s.searchQuery,
129
+ s.epicStatusFilter,
130
+ Math.floor(Date.now() / GRACE_BUCKET_MS),
131
+ ],
122
132
  (epics, searchQuery, epicStatusFilter) => {
123
133
  if (!epics) return [];
124
134
  const now = Date.now();
@@ -225,6 +235,7 @@ function selectSearchScope(state) {
225
235
  * @property {string|null} selectedSubtaskId
226
236
  * @property {object|null} selectedSubtask
227
237
  * @property {boolean} taskModalOpen
238
+ * @property {boolean} subtaskModalOpen
228
239
  * @property {string} search
229
240
  * @property {string} searchQuery
230
241
  * @property {object} searchScope
@@ -232,6 +243,38 @@ function selectSearchScope(state) {
232
243
  * @property {object[]} visibleTasks
233
244
  */
234
245
 
246
+ /**
247
+ * Create a memoizer for deriveBoardState that returns the same reference until
248
+ * invalidate() is called or the hour bucket changes (so time-sensitive
249
+ * selectors like selectVisibleEpics can re-evaluate the 24h grace cutoff).
250
+ * Designed to be invalidated by the store's notify().
251
+ * @param {(state: object) => BoardState} compute
252
+ */
253
+ function createBoardStateMemo(compute) {
254
+ let cachedState = null;
255
+ let cachedBucket = null;
256
+ let cachedResult = null;
257
+ let dirty = true;
258
+
259
+ function get(state) {
260
+ const bucket = Math.floor(Date.now() / GRACE_BUCKET_MS);
261
+ if (!dirty && cachedState === state && cachedBucket === bucket) {
262
+ return cachedResult;
263
+ }
264
+ cachedState = state;
265
+ cachedBucket = bucket;
266
+ cachedResult = compute(state);
267
+ dirty = false;
268
+ return cachedResult;
269
+ }
270
+
271
+ function invalidate() {
272
+ dirty = true;
273
+ }
274
+
275
+ return { get, invalidate };
276
+ }
277
+
235
278
  /**
236
279
  * Compute full derived board state from the internal state, using memoized selectors.
237
280
  * @param {object} state
@@ -266,6 +309,7 @@ function deriveBoardState(state) {
266
309
  const selectedSubtask = selectedTaskId ? selectSelectedSubtask(stateWithTaskSelection) : null;
267
310
 
268
311
  const taskModalOpen = state.taskModalOpen === true;
312
+ const subtaskModalOpen = state.subtaskModalOpen === true && selectedSubtask !== null;
269
313
 
270
314
  return {
271
315
  screen,
@@ -276,6 +320,7 @@ function deriveBoardState(state) {
276
320
  selectedSubtaskId: selectedSubtask?.id ?? null,
277
321
  selectedSubtask,
278
322
  taskModalOpen,
323
+ subtaskModalOpen,
279
324
  search: stateWithScreen.search,
280
325
  searchQuery: stateWithScreen.searchQuery,
281
326
  searchScope: selectSearchScope(stateWithScreen),
@@ -294,6 +339,7 @@ function reconcileBoardState(state) {
294
339
  selectedTaskId: derived.selectedTaskId,
295
340
  selectedSubtaskId: derived.selectedSubtaskId,
296
341
  taskModalOpen: derived.taskModalOpen,
342
+ subtaskModalOpen: derived.subtaskModalOpen,
297
343
  };
298
344
  }
299
345
 
@@ -337,11 +383,13 @@ export function createStore(initialSnapshot, options = {}) {
337
383
  selectedTaskId: typeof storedState.selectedTaskId === "string" ? storedState.selectedTaskId : null,
338
384
  selectedSubtaskId: null,
339
385
  taskModalOpen: false,
386
+ subtaskModalOpen: false,
340
387
  theme: readThemePreference(),
341
388
  focusedEpicIndex: 0,
342
389
  copyFeedback: null,
343
390
  notice: null,
344
391
  isMutating: false,
392
+ dragFeedback: null,
345
393
  notesPanelOpen: storedState.notesPanelOpen === true,
346
394
  epicStatusFilter: readStatusFilter(storedState.epicStatusFilter),
347
395
  taskStatusFilter: readStatusFilter(storedState.taskStatusFilter),
@@ -350,7 +398,10 @@ export function createStore(initialSnapshot, options = {}) {
350
398
  /** @type {Set<(state: object) => void>} */
351
399
  const listeners = new Set();
352
400
 
401
+ const boardStateMemo = createBoardStateMemo(deriveBoardState);
402
+
353
403
  function notify() {
404
+ boardStateMemo.invalidate();
354
405
  for (const listener of listeners) {
355
406
  listener(state);
356
407
  }
@@ -361,12 +412,12 @@ export function createStore(initialSnapshot, options = {}) {
361
412
  screen: state.screen,
362
413
  selectedEpicId: state.selectedEpicId,
363
414
  search: state.search,
364
- view: state.view,
365
- selectedTaskId: state.selectedTaskId,
366
- notesPanelOpen: state.notesPanelOpen,
367
- epicStatusFilter: state.epicStatusFilter,
368
- taskStatusFilter: state.taskStatusFilter,
369
- });
415
+ view: state.view,
416
+ selectedTaskId: state.selectedTaskId,
417
+ notesPanelOpen: state.notesPanelOpen,
418
+ epicStatusFilter: state.epicStatusFilter,
419
+ taskStatusFilter: state.taskStatusFilter,
420
+ });
370
421
  }
371
422
 
372
423
  function syncState(patch = {}) {
@@ -382,7 +433,8 @@ export function createStore(initialSnapshot, options = {}) {
382
433
  || state.view !== reconciled.view
383
434
  || state.selectedTaskId !== reconciled.selectedTaskId
384
435
  || state.selectedSubtaskId !== reconciled.selectedSubtaskId
385
- || state.taskModalOpen !== reconciled.taskModalOpen;
436
+ || state.taskModalOpen !== reconciled.taskModalOpen
437
+ || state.subtaskModalOpen !== reconciled.subtaskModalOpen;
386
438
 
387
439
  state.screen = reconciled.screen;
388
440
  state.selectedEpicId = reconciled.selectedEpicId;
@@ -392,10 +444,11 @@ export function createStore(initialSnapshot, options = {}) {
392
444
  state.selectedTaskId = reconciled.selectedTaskId;
393
445
  state.selectedSubtaskId = reconciled.selectedSubtaskId;
394
446
  state.taskModalOpen = reconciled.taskModalOpen;
447
+ state.subtaskModalOpen = reconciled.subtaskModalOpen;
395
448
  if (changed) {
396
449
  notify();
397
450
  }
398
- return deriveBoardState(state);
451
+ return boardStateMemo.get(state);
399
452
  }
400
453
 
401
454
  // Reconcile initial state
@@ -458,7 +511,7 @@ export function createStore(initialSnapshot, options = {}) {
458
511
  * @returns {BoardState}
459
512
  */
460
513
  getBoardState() {
461
- return deriveBoardState(state);
514
+ return boardStateMemo.get(state);
462
515
  },
463
516
 
464
517
  /**
@@ -536,7 +589,22 @@ export function createStore(initialSnapshot, options = {}) {
536
589
  * @returns {object|null}
537
590
  */
538
591
  getSelectedTask() {
539
- return deriveBoardState(state).selectedTask;
592
+ return boardStateMemo.get(state).selectedTask;
540
593
  },
594
+
595
+ /**
596
+ * Invalidate the memoized board state. Call after a direct mutation of
597
+ * `model.store` that didn't go through `setState`/`syncState`/`notify`,
598
+ * before triggering a rerender, so `getBoardState()` recomputes.
599
+ */
600
+ invalidateBoardStateMemo() {
601
+ boardStateMemo.invalidate();
602
+ },
603
+
604
+ /**
605
+ * Notify subscribers (and invalidate the memo). Use after a direct
606
+ * mutation of `model.store` that needs to be reflected in derived state.
607
+ */
608
+ notify,
541
609
  };
542
610
  }
@@ -47,6 +47,9 @@ export function stateToHash(state) {
47
47
  if (state.selectedTaskId) {
48
48
  params.set("task", state.selectedTaskId);
49
49
  }
50
+ if (state.selectedSubtaskId) {
51
+ params.set("subtask", state.selectedSubtaskId);
52
+ }
50
53
  if (state.screen === "epics" && state.selectedEpicId) {
51
54
  params.set("screen", "epics");
52
55
  }
@@ -73,6 +76,7 @@ export function hashToState(hash) {
73
76
 
74
77
  const epicId = params.get("epic") || null;
75
78
  const taskId = params.get("task") || null;
79
+ const subtaskId = params.get("subtask") || null;
76
80
  const screenParam = params.get("screen");
77
81
  const search = params.get("search") || "";
78
82
  const view = params.get("view") || DEFAULT_VIEW;
@@ -82,9 +86,15 @@ export function hashToState(hash) {
82
86
  ? "tasks"
83
87
  : "epics";
84
88
 
89
+ // Deep-linking with task=/subtask= must open the corresponding modals so the
90
+ // booted page actually renders the deep-linked dialog instead of just
91
+ // selecting a row in the workspace.
85
92
  return {
86
93
  selectedEpicId: epicId,
87
94
  selectedTaskId: taskId,
95
+ selectedSubtaskId: subtaskId,
96
+ taskModalOpen: Boolean(taskId),
97
+ subtaskModalOpen: Boolean(subtaskId),
88
98
  search,
89
99
  view,
90
100
  screen,
@@ -311,7 +311,8 @@ export function escapeHtml(value) {
311
311
  .replaceAll("&", "&amp;")
312
312
  .replaceAll("<", "&lt;")
313
313
  .replaceAll(">", "&gt;")
314
- .replaceAll('"', "&quot;");
314
+ .replaceAll('"', "&quot;")
315
+ .replaceAll("'", "&#39;");
315
316
  }
316
317
 
317
318
  /**
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Board event bus.
3
+ *
4
+ * Per-server-instance pub/sub used to broadcast snapshot deltas to SSE
5
+ * subscribers (browser tabs). Publishers (board route handlers, future WAL
6
+ * watcher) call publish; subscribers receive the JSON-serializable payload.
7
+ */
8
+
9
+ export interface BoardDeltaEvent {
10
+ readonly type: "snapshotDelta";
11
+ readonly id: number;
12
+ readonly snapshotDelta: Record<string, unknown>;
13
+ }
14
+
15
+ export type BoardEvent = BoardDeltaEvent;
16
+
17
+ export type BoardEventListener = (event: BoardEvent) => void;
18
+
19
+ export interface BoardEventBus {
20
+ publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent;
21
+ subscribe(listener: BoardEventListener): () => void;
22
+ readonly subscriberCount: number;
23
+ close(): void;
24
+ }
25
+
26
+ export function createBoardEventBus(): BoardEventBus {
27
+ const listeners = new Set<BoardEventListener>();
28
+ let nextId = 1;
29
+ let closed = false;
30
+
31
+ return {
32
+ publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent {
33
+ const event: BoardDeltaEvent = {
34
+ type: "snapshotDelta",
35
+ id: nextId++,
36
+ snapshotDelta,
37
+ };
38
+
39
+ if (closed) {
40
+ return event;
41
+ }
42
+
43
+ // Snapshot to allow listeners to unsubscribe during dispatch.
44
+ for (const listener of [...listeners]) {
45
+ try {
46
+ listener(event);
47
+ } catch {
48
+ // Listener errors must not block other subscribers.
49
+ }
50
+ }
51
+
52
+ return event;
53
+ },
54
+ subscribe(listener: BoardEventListener): () => void {
55
+ if (closed) {
56
+ return () => {};
57
+ }
58
+
59
+ listeners.add(listener);
60
+ return () => {
61
+ listeners.delete(listener);
62
+ };
63
+ },
64
+ get subscriberCount(): number {
65
+ return listeners.size;
66
+ },
67
+ close(): void {
68
+ closed = true;
69
+ listeners.clear();
70
+ },
71
+ };
72
+ }