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
@@ -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
- const inputs = getManagedControls(container);
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);
@@ -166,7 +166,7 @@ function renderWorkspaceHeader(props) {
166
166
  // ---------------------------------------------------------------------------
167
167
 
168
168
  function renderKanbanColumns(props) {
169
- const { visibleTasks, selectedTaskId, isMutating, taskStatusFilter } = props;
169
+ const { visibleTasks, selectedTaskId, isMutating, taskStatusFilter, dragFeedback } = props;
170
170
  const filter = taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
171
171
 
172
172
  const columnsMarkup = STATUS_ORDER
@@ -178,6 +178,12 @@ function renderKanbanColumns(props) {
178
178
  ? renderEmptyState(`No ${columnTitle.toLowerCase()} work`, "Adjust search or switch epics to inspect more tasks.")
179
179
  : columnTasks.map((task) => renderTaskCard({ task, selected: selectedTaskId === task.id, isMutating })).join("");
180
180
 
181
+ // Re-apply drag-feedback class from store so a rerender during drag does
182
+ // not silently wipe the visual feedback that was set by handleDragover.
183
+ const feedbackClass = dragFeedback?.targetStatus === status
184
+ ? (dragFeedback.kind === "valid" ? " board-drop-valid" : " board-drop-invalid")
185
+ : "";
186
+
181
187
  return `
182
188
  <section class="board-column board-column--dense ${secondaryPanelClasses("flex min-h-[20rem] min-w-0 flex-col p-3")}" aria-labelledby="column-${status}">
183
189
  <header class="board-column__header flex items-start justify-between gap-3 border-b border-[var(--board-border)] pb-3">
@@ -190,7 +196,7 @@ function renderKanbanColumns(props) {
190
196
  </div>
191
197
  ${columnTasks.length > 0 ? `<span class="board-column__count text-xs font-medium text-[var(--board-text-soft)]">${columnTasks.length === 1 ? "1 task" : `${columnTasks.length} tasks`}</span>` : ""}
192
198
  </header>
193
- <div class="board-column__tasks mt-3 grid min-h-0 flex-1 content-start gap-2.5 overflow-auto pr-1 overscroll-contain" id="column-${status}" data-drop-status="${escapeHtml(status)}">${content}</div>
199
+ <div class="board-column__tasks mt-3 grid min-h-0 flex-1 content-start gap-2.5 overflow-auto pr-1 overscroll-contain${feedbackClass}" id="column-${status}" data-drop-status="${escapeHtml(status)}">${content}</div>
194
200
  </section>
195
201
  `;
196
202
  }).join("");
@@ -292,7 +298,7 @@ function render(props) {
292
298
  });
293
299
 
294
300
  const contentMarkup = store.view === "kanban"
295
- ? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating, taskStatusFilter: store.taskStatusFilter })
301
+ ? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating, taskStatusFilter: store.taskStatusFilter, dragFeedback: store.dragFeedback ?? null })
296
302
  : renderListView({ visibleTasks, selectedTaskId });
297
303
 
298
304
  return `
@@ -252,7 +252,11 @@ export function isCompactViewport() {
252
252
  }
253
253
 
254
254
  export function shouldUseTaskModal(boardState, store) {
255
- return Boolean(boardState?.selectedTask);
255
+ return Boolean(boardState?.taskModalOpen && boardState?.selectedTask);
256
+ }
257
+
258
+ export function shouldUseSubtaskModal(boardState) {
259
+ return Boolean(boardState?.subtaskModalOpen && boardState?.selectedSubtask);
256
260
  }
257
261
 
258
262
  export function lookupNode(snapshot, id) {
@@ -310,6 +310,8 @@ export function createDelegation(rootElement, actions) {
310
310
  for (const el of rootElement.querySelectorAll(".board-drop-valid, .board-drop-invalid")) {
311
311
  el.classList.remove("board-drop-valid", "board-drop-invalid");
312
312
  }
313
+ // Clear feedback in store so rerenders do not re-apply stale classes.
314
+ actions.setDragFeedback?.(null);
313
315
  }
314
316
 
315
317
  function handleDragstart(event) {
@@ -336,11 +338,15 @@ export function createDelegation(rootElement, actions) {
336
338
  const targetStatus = column.dataset.dropStatus;
337
339
  if (draggedTaskStatus && isValidTransition(draggedTaskStatus, targetStatus)) {
338
340
  event.preventDefault();
341
+ // Fast path: apply directly to DOM for immediate visual feedback.
339
342
  column.classList.add("board-drop-valid");
340
343
  column.classList.remove("board-drop-invalid");
344
+ // Also persist in store so a rerender during drag restores the class.
345
+ actions.setDragFeedback?.({ targetStatus, kind: "valid" });
341
346
  } else if (draggedTaskStatus && targetStatus !== draggedTaskStatus) {
342
347
  column.classList.add("board-drop-invalid");
343
348
  column.classList.remove("board-drop-valid");
349
+ actions.setDragFeedback?.({ targetStatus, kind: "invalid" });
344
350
  }
345
351
  }
346
352
 
@@ -348,6 +354,8 @@ export function createDelegation(rootElement, actions) {
348
354
  const column = event.target.closest("[data-drop-status]");
349
355
  if (column && !column.contains(event.relatedTarget)) {
350
356
  column.classList.remove("board-drop-valid", "board-drop-invalid");
357
+ // Clear store feedback when leaving the column.
358
+ actions.setDragFeedback?.(null);
351
359
  }
352
360
  }
353
361
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Lazy overlay focus-trap controller.
3
+ *
4
+ * Attaches the document-level keydown/focusin listeners only while an overlay
5
+ * is actually open, and detaches them on close. Without this, plain Tab outside
6
+ * any overlay can be intercepted by a stale handler in the microtask window
7
+ * between overlay close and rerender.
8
+ *
9
+ * @param {{
10
+ * doc?: Document,
11
+ * onTabKey: (event: KeyboardEvent) => void,
12
+ * onFocusIn: (event: FocusEvent) => void,
13
+ * }} options
14
+ */
15
+ export function createOverlayFocusTrap(options) {
16
+ const doc = options.doc ?? (typeof document !== "undefined" ? document : null);
17
+ if (!doc) {
18
+ return {
19
+ attach() {},
20
+ detach() {},
21
+ isAttached() { return false; },
22
+ };
23
+ }
24
+
25
+ const onKeyDown = options.onTabKey;
26
+ const onFocusIn = options.onFocusIn;
27
+ let attached = false;
28
+
29
+ function attach() {
30
+ if (attached) return;
31
+ doc.addEventListener("keydown", onKeyDown, true);
32
+ doc.addEventListener("focusin", onFocusIn, true);
33
+ attached = true;
34
+ }
35
+
36
+ function detach() {
37
+ if (!attached) return;
38
+ doc.removeEventListener("keydown", onKeyDown, true);
39
+ doc.removeEventListener("focusin", onFocusIn, true);
40
+ attached = false;
41
+ }
42
+
43
+ return {
44
+ attach,
45
+ detach,
46
+ isAttached() { return attached; },
47
+ };
48
+ }
@@ -142,6 +142,13 @@ export function createBoardActions(options) {
142
142
  searchFocusKeys,
143
143
  } = options;
144
144
  const { store, persist, getBoardState, getTaskById, syncState } = model;
145
+ // Direct mutations of `store.*` (filter toggles, notice changes, drag
146
+ // feedback) bypass setState/syncState, so `getBoardState()`'s memo would
147
+ // otherwise return a stale reference until the next syncState/notify.
148
+ // Route these through invalidateBoardStateMemo() before rerender.
149
+ const invalidateMemo = typeof model.invalidateBoardStateMemo === "function"
150
+ ? () => model.invalidateBoardStateMemo()
151
+ : () => {};
145
152
 
146
153
  const transition = (patch = {}, options = {}) => {
147
154
  const { persistState = true, rerenderBoard = true } = options;
@@ -263,6 +270,7 @@ export function createBoardActions(options) {
263
270
  selectedEpicId: epicId || null,
264
271
  selectedTaskId: null,
265
272
  selectedSubtaskId: null,
273
+ taskModalOpen: false,
266
274
  });
267
275
  },
268
276
  selectEpic(epicId) {
@@ -271,6 +279,7 @@ export function createBoardActions(options) {
271
279
  selectedEpicId: epicId || null,
272
280
  selectedTaskId: null,
273
281
  selectedSubtaskId: null,
282
+ taskModalOpen: false,
274
283
  });
275
284
  },
276
285
  showEpics() {
@@ -278,6 +287,7 @@ export function createBoardActions(options) {
278
287
  screen: "epics",
279
288
  selectedTaskId: null,
280
289
  selectedSubtaskId: null,
290
+ taskModalOpen: false,
281
291
  });
282
292
  },
283
293
  showBoard() {
@@ -295,6 +305,7 @@ export function createBoardActions(options) {
295
305
  selectedEpicId: fallbackEpicId,
296
306
  selectedTaskId: null,
297
307
  selectedSubtaskId: null,
308
+ taskModalOpen: false,
298
309
  });
299
310
  },
300
311
  setView(view) {
@@ -340,16 +351,25 @@ export function createBoardActions(options) {
340
351
  screen: "tasks",
341
352
  selectedEpicId: task.epicId,
342
353
  selectedTaskId: taskId,
354
+ taskModalOpen: true,
343
355
  });
344
356
  },
345
357
  closeTask() {
346
- transition({ selectedTaskId: null, selectedSubtaskId: null });
358
+ transition({
359
+ selectedTaskId: null,
360
+ selectedSubtaskId: null,
361
+ taskModalOpen: false,
362
+ subtaskModalOpen: false,
363
+ });
347
364
  },
348
365
  openSubtask(subtaskId) {
349
- transition({ selectedSubtaskId: subtaskId || null }, { persistState: false });
366
+ transition(
367
+ { selectedSubtaskId: subtaskId || null, subtaskModalOpen: Boolean(subtaskId) },
368
+ { persistState: false },
369
+ );
350
370
  },
351
371
  closeSubtask() {
352
- transition({ selectedSubtaskId: null }, { persistState: false });
372
+ transition({ selectedSubtaskId: null, subtaskModalOpen: false }, { persistState: false });
353
373
  },
354
374
  submitTaskForm(taskId, formData) {
355
375
  const updates = {
@@ -408,12 +428,31 @@ export function createBoardActions(options) {
408
428
  const task = getTaskById(taskId);
409
429
  return task?.status ?? null;
410
430
  },
431
+ /**
432
+ * Record the current drag-over feedback in store state so it survives a
433
+ * rerender triggered by an unrelated event (e.g. a server SSE push).
434
+ * Pass null to clear feedback (dragend / drop / dragleave).
435
+ *
436
+ * @param {{ targetStatus: string, kind: 'valid'|'invalid' }|null} feedback
437
+ */
438
+ setDragFeedback(feedback) {
439
+ // Reference equality on freshly-allocated `{ targetStatus, kind }` always
440
+ // fails, so dragover would rerender on every move. Compare by primitive
441
+ // key so repeats over the same column become a no-op.
442
+ const prev = store.dragFeedback;
443
+ const prevKey = prev ? `${prev.targetStatus}|${prev.kind}` : null;
444
+ const nextKey = feedback ? `${feedback.targetStatus}|${feedback.kind}` : null;
445
+ if (prevKey === nextKey) return;
446
+ store.dragFeedback = feedback;
447
+ rerender();
448
+ },
411
449
  dropTaskStatus(taskId, nextStatus) {
412
450
  const task = getTaskById(taskId);
413
451
  if (!task || !nextStatus || task.status === nextStatus) {
414
452
  return;
415
453
  }
416
- transition({ selectedTaskId: taskId }, { rerenderBoard: false });
454
+ // Drag/drop is a status change only; do not mutate selection or modal state,
455
+ // otherwise dropping a card while another task modal is open hijacks it.
417
456
  api.patchTask(taskId, { status: nextStatus }, (snapshot) => updateTaskInSnapshot(snapshot, taskId, { status: nextStatus }, normalizeSnapshot));
418
457
  },
419
458
  changeEpicStatus(epicId, newStatus) {
@@ -433,22 +472,26 @@ export function createBoardActions(options) {
433
472
  toggleEpicStatusFilter(status) {
434
473
  const current = store.epicStatusFilter || { ...DEFAULT_STATUS_FILTER };
435
474
  store.epicStatusFilter = { ...current, [status]: !current[status] };
475
+ invalidateMemo();
436
476
  persist();
437
477
  rerender();
438
478
  },
439
479
  toggleTaskStatusFilter(status) {
440
480
  const current = store.taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
441
481
  store.taskStatusFilter = { ...current, [status]: !current[status] };
482
+ invalidateMemo();
442
483
  persist();
443
484
  rerender();
444
485
  },
445
486
  resetEpicFilter() {
446
487
  store.epicStatusFilter = { ...DEFAULT_STATUS_FILTER };
488
+ invalidateMemo();
447
489
  persist();
448
490
  rerender();
449
491
  },
450
492
  resetTaskFilter() {
451
493
  store.taskStatusFilter = { ...DEFAULT_STATUS_FILTER };
494
+ invalidateMemo();
452
495
  persist();
453
496
  rerender();
454
497
  },