trekoon 0.3.1 → 0.3.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.
@@ -1,3 +1,5 @@
1
+ import { isValidTransition } from "../state/utils.js";
2
+
1
3
  /**
2
4
  * Event delegation system for the board runtime.
3
5
  *
@@ -89,6 +91,31 @@ export function createDelegation(rootElement, actions) {
89
91
  return;
90
92
  }
91
93
 
94
+ // -- Status filter pills ---------------------------------------------------
95
+ const epicFilterEl = target.closest("[data-toggle-epic-status-filter]");
96
+ if (epicFilterEl) {
97
+ actions.toggleEpicStatusFilter(epicFilterEl.dataset.toggleEpicStatusFilter);
98
+ return;
99
+ }
100
+
101
+ const taskFilterEl = target.closest("[data-toggle-task-status-filter]");
102
+ if (taskFilterEl) {
103
+ actions.toggleTaskStatusFilter(taskFilterEl.dataset.toggleTaskStatusFilter);
104
+ return;
105
+ }
106
+
107
+ const resetEpicFilterEl = target.closest("[data-reset-epic-filter]");
108
+ if (resetEpicFilterEl) {
109
+ actions.resetEpicFilter();
110
+ return;
111
+ }
112
+
113
+ const resetTaskFilterEl = target.closest("[data-reset-task-filter]");
114
+ if (resetTaskFilterEl) {
115
+ actions.resetTaskFilter();
116
+ return;
117
+ }
118
+
92
119
  // -- Navigation -----------------------------------------------------------
93
120
 
94
121
  const navEl = target.closest("[data-nav]");
@@ -276,6 +303,15 @@ export function createDelegation(rootElement, actions) {
276
303
  // ---------------------------------------------------------------------------
277
304
  // Drag-and-drop delegation
278
305
  // ---------------------------------------------------------------------------
306
+ let draggedTaskStatus = null;
307
+
308
+ function cleanupDragFeedback() {
309
+ draggedTaskStatus = null;
310
+ for (const el of rootElement.querySelectorAll(".board-drop-valid, .board-drop-invalid")) {
311
+ el.classList.remove("board-drop-valid", "board-drop-invalid");
312
+ }
313
+ }
314
+
279
315
  function handleDragstart(event) {
280
316
  const draggable = event.target.closest("[data-draggable-task]");
281
317
  if (!draggable) return;
@@ -290,11 +326,28 @@ export function createDelegation(rootElement, actions) {
290
326
 
291
327
  event.dataTransfer?.setData("text/task-id", taskId);
292
328
  event.dataTransfer?.setData("text/plain", taskId);
329
+ draggedTaskStatus = actions.getTaskStatus(taskId);
293
330
  }
294
331
 
295
332
  function handleDragover(event) {
296
- if (event.target.closest("[data-drop-status]")) {
333
+ const column = event.target.closest("[data-drop-status]");
334
+ if (!column) return;
335
+
336
+ const targetStatus = column.dataset.dropStatus;
337
+ if (draggedTaskStatus && isValidTransition(draggedTaskStatus, targetStatus)) {
297
338
  event.preventDefault();
339
+ column.classList.add("board-drop-valid");
340
+ column.classList.remove("board-drop-invalid");
341
+ } else if (draggedTaskStatus && targetStatus !== draggedTaskStatus) {
342
+ column.classList.add("board-drop-invalid");
343
+ column.classList.remove("board-drop-valid");
344
+ }
345
+ }
346
+
347
+ function handleDragleave(event) {
348
+ const column = event.target.closest("[data-drop-status]");
349
+ if (column && !column.contains(event.relatedTarget)) {
350
+ column.classList.remove("board-drop-valid", "board-drop-invalid");
298
351
  }
299
352
  }
300
353
 
@@ -309,7 +362,18 @@ export function createDelegation(rootElement, actions) {
309
362
  event.dataTransfer?.getData("text/task-id") ||
310
363
  event.dataTransfer?.getData("text/plain");
311
364
  const nextStatus = column.dataset.dropStatus;
365
+
366
+ if (draggedTaskStatus && !isValidTransition(draggedTaskStatus, nextStatus)) {
367
+ cleanupDragFeedback();
368
+ return;
369
+ }
370
+
312
371
  actions.dropTaskStatus(taskId, nextStatus);
372
+ cleanupDragFeedback();
373
+ }
374
+
375
+ function handleDragend() {
376
+ cleanupDragFeedback();
313
377
  }
314
378
 
315
379
  // ---------------------------------------------------------------------------
@@ -321,7 +385,9 @@ export function createDelegation(rootElement, actions) {
321
385
  rootElement.addEventListener("submit", handleSubmit);
322
386
  rootElement.addEventListener("dragstart", handleDragstart);
323
387
  rootElement.addEventListener("dragover", handleDragover);
388
+ rootElement.addEventListener("dragleave", handleDragleave);
324
389
  rootElement.addEventListener("drop", handleDrop);
390
+ rootElement.addEventListener("dragend", handleDragend);
325
391
  rootElement.addEventListener("keydown", handleDelegatedKeydown);
326
392
  window.addEventListener("keydown", handleKeydown);
327
393
 
@@ -335,7 +401,9 @@ export function createDelegation(rootElement, actions) {
335
401
  rootElement.removeEventListener("submit", handleSubmit);
336
402
  rootElement.removeEventListener("dragstart", handleDragstart);
337
403
  rootElement.removeEventListener("dragover", handleDragover);
404
+ rootElement.removeEventListener("dragleave", handleDragleave);
338
405
  rootElement.removeEventListener("drop", handleDrop);
406
+ rootElement.removeEventListener("dragend", handleDragend);
339
407
  rootElement.removeEventListener("keydown", handleDelegatedKeydown);
340
408
  window.removeEventListener("keydown", handleKeydown);
341
409
  };
@@ -1,5 +1,5 @@
1
1
  import { copyTextToClipboard } from "../runtime/clipboard.js";
2
- import { orderEpicsNewestFirst } from "./store.js";
2
+ import { DEFAULT_STATUS_FILTER, orderEpicsNewestFirst } from "./store.js";
3
3
 
4
4
  function cloneSnapshot(snapshot) {
5
5
  if (typeof structuredClone === "function") {
@@ -404,6 +404,10 @@ export function createBoardActions(options) {
404
404
  removeDependency(sourceId, dependsOnId) {
405
405
  api.removeDependency(sourceId, dependsOnId, (snapshot) => removeDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot));
406
406
  },
407
+ getTaskStatus(taskId) {
408
+ const task = getTaskById(taskId);
409
+ return task?.status ?? null;
410
+ },
407
411
  dropTaskStatus(taskId, nextStatus) {
408
412
  const task = getTaskById(taskId);
409
413
  if (!task || !nextStatus || task.status === nextStatus) {
@@ -426,6 +430,28 @@ export function createBoardActions(options) {
426
430
  cascadeEpicStatusInSnapshot(snapshot, epicId, normalizedStatus, normalizeSnapshot),
427
431
  );
428
432
  },
433
+ toggleEpicStatusFilter(status) {
434
+ const current = store.epicStatusFilter || { ...DEFAULT_STATUS_FILTER };
435
+ store.epicStatusFilter = { ...current, [status]: !current[status] };
436
+ persist();
437
+ rerender();
438
+ },
439
+ toggleTaskStatusFilter(status) {
440
+ const current = store.taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
441
+ store.taskStatusFilter = { ...current, [status]: !current[status] };
442
+ persist();
443
+ rerender();
444
+ },
445
+ resetEpicFilter() {
446
+ store.epicStatusFilter = { ...DEFAULT_STATUS_FILTER };
447
+ persist();
448
+ rerender();
449
+ },
450
+ resetTaskFilter() {
451
+ store.taskStatusFilter = { ...DEFAULT_STATUS_FILTER };
452
+ persist();
453
+ rerender();
454
+ },
429
455
  handleKeydown(event) {
430
456
  const boardState = getBoardState();
431
457
  const activeElement = document.activeElement;
@@ -9,6 +9,18 @@ function normalizeSearch(value) {
9
9
 
10
10
  // --- Persistence helpers ---
11
11
 
12
+ export const DEFAULT_STATUS_FILTER = { todo: true, blocked: true, in_progress: true, done: false };
13
+
14
+ function readStatusFilter(raw) {
15
+ if (typeof raw !== "object" || raw === null) return { ...DEFAULT_STATUS_FILTER };
16
+ return {
17
+ todo: typeof raw.todo === "boolean" ? raw.todo : DEFAULT_STATUS_FILTER.todo,
18
+ blocked: typeof raw.blocked === "boolean" ? raw.blocked : DEFAULT_STATUS_FILTER.blocked,
19
+ in_progress: typeof raw.in_progress === "boolean" ? raw.in_progress : DEFAULT_STATUS_FILTER.in_progress,
20
+ done: typeof raw.done === "boolean" ? raw.done : DEFAULT_STATUS_FILTER.done,
21
+ };
22
+ }
23
+
12
24
  export function readStoredState() {
13
25
  try {
14
26
  return JSON.parse(localStorage.getItem(STATE_STORAGE_KEY) || "{}");
@@ -77,13 +89,25 @@ export function orderEpicsNewestFirst(epics) {
77
89
 
78
90
  // --- Derived state selectors ---
79
91
 
92
+ /** Recently-done epics stay visible for 24h even when the "done" filter is off. */
93
+ const DONE_GRACE_PERIOD_MS = 86400000;
94
+
80
95
  const selectVisibleEpics = createSelector(
81
- (s) => [s.snapshot?.epics, s.searchQuery],
82
- (epics, searchQuery) => {
96
+ (s) => [s.snapshot?.epics, s.searchQuery, s.epicStatusFilter],
97
+ (epics, searchQuery, epicStatusFilter) => {
83
98
  if (!epics) return [];
99
+ const now = Date.now();
100
+ const filtered = epics.filter((epic) => {
101
+ if (epic.status === "done") {
102
+ if (!epicStatusFilter.done && (now - epic.updatedAt) > DONE_GRACE_PERIOD_MS) return false;
103
+ } else if (!epicStatusFilter[epic.status]) {
104
+ return false;
105
+ }
106
+ return true;
107
+ });
84
108
  const matchingEpics = searchQuery.length === 0
85
- ? epics
86
- : epics.filter((epic) => epic.searchText.includes(searchQuery));
109
+ ? filtered
110
+ : filtered.filter((epic) => epic.searchText.includes(searchQuery));
87
111
 
88
112
  return orderEpicsNewestFirst(matchingEpics);
89
113
  },
@@ -100,11 +124,12 @@ const selectTasksInScope = createSelector(
100
124
  );
101
125
 
102
126
  const selectVisibleTasks = createSelector(
103
- (s) => [selectTasksInScope(s), s.searchQuery],
104
- (tasksInScope, searchQuery) => {
127
+ (s) => [selectTasksInScope(s), s.searchQuery, s.taskStatusFilter],
128
+ (tasksInScope, searchQuery, taskStatusFilter) => {
129
+ const filtered = tasksInScope.filter((task) => taskStatusFilter[task.status]);
105
130
  return searchQuery.length === 0
106
- ? tasksInScope
107
- : tasksInScope.filter((task) => task.searchText.includes(searchQuery));
131
+ ? filtered
132
+ : filtered.filter((task) => task.searchText.includes(searchQuery));
108
133
  },
109
134
  );
110
135
 
@@ -287,6 +312,8 @@ export function createStore(initialSnapshot, options = {}) {
287
312
  notice: null,
288
313
  isMutating: false,
289
314
  notesPanelOpen: storedState.notesPanelOpen === true,
315
+ epicStatusFilter: readStatusFilter(storedState.epicStatusFilter),
316
+ taskStatusFilter: readStatusFilter(storedState.taskStatusFilter),
290
317
  };
291
318
 
292
319
  /** @type {Set<(state: object) => void>} */
@@ -306,6 +333,8 @@ export function createStore(initialSnapshot, options = {}) {
306
333
  view: state.view,
307
334
  selectedTaskId: state.selectedTaskId,
308
335
  notesPanelOpen: state.notesPanelOpen,
336
+ epicStatusFilter: state.epicStatusFilter,
337
+ taskStatusFilter: state.taskStatusFilter,
309
338
  });
310
339
  }
311
340
 
@@ -220,3 +220,45 @@ export function escapeHtml(value) {
220
220
  .replaceAll(">", "&gt;")
221
221
  .replaceAll('"', "&quot;");
222
222
  }
223
+
224
+ /**
225
+ * Valid status transitions mirroring the backend state machine (src/domain/types.ts).
226
+ * @type {Map<string, Set<string>>}
227
+ */
228
+ export const VALID_TRANSITIONS = new Map([
229
+ ["todo", new Set(["in_progress", "blocked"])],
230
+ ["in_progress", new Set(["done", "blocked"])],
231
+ ["blocked", new Set(["in_progress", "todo"])],
232
+ ["done", new Set(["in_progress"])],
233
+ ]);
234
+
235
+ /**
236
+ * Get the list of statuses a node can transition to from its current status.
237
+ * @param {string} currentStatus
238
+ * @returns {string[]}
239
+ */
240
+ export function getValidTargets(currentStatus) {
241
+ const targets = VALID_TRANSITIONS.get(currentStatus);
242
+ return targets ? Array.from(targets) : [];
243
+ }
244
+
245
+ /**
246
+ * Check whether transitioning from one status to another is valid.
247
+ * @param {string} from
248
+ * @param {string} to
249
+ * @returns {boolean}
250
+ */
251
+ export function isValidTransition(from, to) {
252
+ const targets = VALID_TRANSITIONS.get(from);
253
+ return targets ? targets.has(to) : false;
254
+ }
255
+
256
+ /**
257
+ * Return the current status plus all valid targets, useful for populating
258
+ * status select dropdowns.
259
+ * @param {string} currentStatus
260
+ * @returns {string[]}
261
+ */
262
+ export function getSelectableStatuses(currentStatus) {
263
+ return [currentStatus, ...getValidTargets(currentStatus)];
264
+ }
@@ -1929,3 +1929,71 @@ textarea,
1929
1929
  .\!px-2 { padding-inline: 0.5rem !important; }
1930
1930
  .\!text-xs { font-size: 0.75rem !important; line-height: 1rem !important; }
1931
1931
  .\!min-h-0 { min-height: 0 !important; }
1932
+
1933
+ /* --- Drag-and-drop transition feedback --- */
1934
+ .board-drop-valid {
1935
+ outline: 2px solid var(--board-accent);
1936
+ outline-offset: -2px;
1937
+ background: rgba(var(--board-accent-rgb, 99, 102, 241), 0.06);
1938
+ }
1939
+
1940
+ .board-drop-invalid {
1941
+ outline: 2px dashed rgba(239, 68, 68, 0.4);
1942
+ outline-offset: -2px;
1943
+ opacity: 0.6;
1944
+ cursor: not-allowed;
1945
+ }
1946
+
1947
+ /* --- Filter pills --- */
1948
+ .board-filter-bar {
1949
+ display: flex;
1950
+ flex-wrap: wrap;
1951
+ align-items: center;
1952
+ gap: 0.375rem;
1953
+ margin-top: 0.75rem;
1954
+ }
1955
+
1956
+ .board-filter-pill {
1957
+ display: inline-flex;
1958
+ align-items: center;
1959
+ gap: 0.25rem;
1960
+ padding: 0.25rem 0.75rem;
1961
+ border-radius: 9999px;
1962
+ border: 1px solid var(--board-border);
1963
+ background: var(--board-surface-2);
1964
+ color: var(--board-text-muted);
1965
+ font-size: 0.6875rem;
1966
+ font-weight: 600;
1967
+ text-transform: uppercase;
1968
+ letter-spacing: 0.14em;
1969
+ cursor: pointer;
1970
+ transition: opacity 0.2s, border-color 0.2s, background 0.2s;
1971
+ user-select: none;
1972
+ }
1973
+
1974
+ .board-filter-pill:hover {
1975
+ border-color: var(--board-border-strong);
1976
+ background: rgba(255, 255, 255, 0.08);
1977
+ }
1978
+
1979
+ .board-filter-pill:focus-visible {
1980
+ outline: none;
1981
+ box-shadow: 0 0 0 2px var(--board-border-strong);
1982
+ }
1983
+
1984
+ .board-filter-pill--active {
1985
+ opacity: 1;
1986
+ }
1987
+
1988
+ .board-filter-pill--inactive {
1989
+ opacity: 0.4;
1990
+ }
1991
+
1992
+ .board-filter-pill--reset {
1993
+ color: var(--board-accent);
1994
+ border-color: var(--board-accent);
1995
+ }
1996
+
1997
+ .board-filter-pill--reset:hover {
1998
+ background: rgba(var(--board-accent-rgb, 99, 102, 241), 0.1);
1999
+ }
@@ -1,4 +1,4 @@
1
- import { copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
1
+ import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
@@ -79,6 +79,10 @@ function resolveBundledSkillFilePath(): string {
79
79
  return fileURLToPath(new URL("../../.agents/skills/trekoon/SKILL.md", import.meta.url));
80
80
  }
81
81
 
82
+ function resolveBundledSkillDirPath(): string {
83
+ return fileURLToPath(new URL("../../.agents/skills/trekoon", import.meta.url));
84
+ }
85
+
82
86
  function toAbsolutePath(cwd: string, pathValue: string): string {
83
87
  if (isAbsolute(pathValue)) {
84
88
  return pathValue;
@@ -234,6 +238,7 @@ function resolveEditorConfigDir(cwd: string, editor: EditorName): string {
234
238
 
235
239
  function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; installedPath: string; installedDir: string } {
236
240
  const sourcePath: string = resolveBundledSkillFilePath();
241
+ const sourceDir: string = resolveBundledSkillDirPath();
237
242
  if (!existsSync(sourcePath)) {
238
243
  return failResult({
239
244
  command: "skills.install",
@@ -255,6 +260,12 @@ function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; i
255
260
  try {
256
261
  mkdirSync(installedDir, { recursive: true });
257
262
  copyFileSync(sourcePath, installedPath);
263
+ // Copy reference guides if they exist in the bundled source.
264
+ const sourceRefDir: string = join(sourceDir, "reference");
265
+ if (existsSync(sourceRefDir)) {
266
+ const installedRefDir: string = join(installedDir, "reference");
267
+ cpSync(sourceRefDir, installedRefDir, { recursive: true });
268
+ }
258
269
  } catch (error: unknown) {
259
270
  const message = error instanceof Error ? error.message : "Unknown skills install failure";
260
271
  return failResult({
@@ -298,39 +309,28 @@ function replaceOrCreateSymlink(
298
309
 
299
310
  const existing = lstatSync(linkPath);
300
311
  if (!existing.isSymbolicLink()) {
301
- return failResult({
302
- command: "skills.install",
303
- human: `Cannot create symlink: path exists and is not a link (${linkPath}).`,
304
- data: {
305
- code: "path_conflict",
306
- linkPath,
307
- targetPath,
308
- },
309
- error: {
310
- code: "path_conflict",
311
- message: "Symlink destination exists as a non-link path",
312
- },
313
- });
312
+ // Replace stale directory or file with symlink to the canonical location.
313
+ rmSync(linkPath, { recursive: true, force: true });
314
+ const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
315
+ if (boundaryFailure) {
316
+ return boundaryFailure;
317
+ }
318
+ symlinkSync(symlinkTarget, linkPath, "dir");
319
+ return null;
314
320
  }
315
321
 
316
322
  const existingRawTarget: string = readlinkSync(linkPath);
317
323
  const existingAbsoluteTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
318
324
  const expectedTarget: string = resolve(targetPath);
319
325
  if (existingAbsoluteTarget !== expectedTarget) {
320
- return failResult({
321
- command: "skills.install",
322
- human: `Cannot replace existing link at ${linkPath}; it points to ${existingAbsoluteTarget}.`,
323
- data: {
324
- code: "path_conflict",
325
- linkPath,
326
- existingTarget: existingAbsoluteTarget,
327
- expectedTarget,
328
- },
329
- error: {
330
- code: "path_conflict",
331
- message: "Symlink destination points to a different target",
332
- },
333
- });
326
+ // Replace symlink pointing to a different target.
327
+ rmSync(linkPath, { force: true });
328
+ const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
329
+ if (boundaryFailure) {
330
+ return boundaryFailure;
331
+ }
332
+ symlinkSync(symlinkTarget, linkPath, "dir");
333
+ return null;
334
334
  }
335
335
 
336
336
  rmSync(linkPath, { force: true });
@@ -525,12 +525,16 @@ function updateEditorLink(
525
525
 
526
526
  const entry = lstatSync(linkPath);
527
527
  if (!entry.isSymbolicLink()) {
528
+ // Replace stale directory or file with symlink to the canonical location.
529
+ rmSync(linkPath, { recursive: true, force: true });
530
+ mkdirSync(dirname(linkPath), { recursive: true });
531
+ symlinkSync(symlinkTarget, linkPath, "dir");
528
532
  return {
529
533
  editor,
530
534
  linkPath,
531
535
  expectedTarget,
532
- action: "skipped_conflict",
533
- conflictCode: "non_link",
536
+ action: "refreshed",
537
+ conflictCode: null,
534
538
  existingTarget: null,
535
539
  };
536
540
  }
@@ -539,12 +543,15 @@ function updateEditorLink(
539
543
  const existingTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
540
544
 
541
545
  if (existingTarget !== expectedTarget) {
546
+ // Replace symlink pointing to a different target.
547
+ rmSync(linkPath, { force: true });
548
+ symlinkSync(symlinkTarget, linkPath, "dir");
542
549
  return {
543
550
  editor,
544
551
  linkPath,
545
552
  expectedTarget,
546
- action: "skipped_conflict",
547
- conflictCode: "wrong_target",
553
+ action: "refreshed",
554
+ conflictCode: null,
548
555
  existingTarget,
549
556
  };
550
557
  }