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
|
@@ -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) => [
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
@@ -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
|
+
}
|