trekoon 0.3.1 → 0.3.3

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
 
@@ -143,7 +143,7 @@ export function normalizeSnapshot(rawSnapshot) {
143
143
  id: epicId,
144
144
  title: String(epic.title ?? "Untitled epic"),
145
145
  description: String(epic.description ?? "").replace(/\\n/g, "\n"),
146
- status: String(epic.status ?? "todo"),
146
+ status: normalizeStatus(String(epic.status ?? "todo")),
147
147
  createdAt: Number(epic.createdAt ?? Date.now()),
148
148
  updatedAt: Number(epic.updatedAt ?? epic.createdAt ?? Date.now()),
149
149
  taskIds: epicTasks.map((task) => task.id),
@@ -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
+ }
@@ -42,6 +42,7 @@ export interface ParsedCompactFields {
42
42
  }
43
43
 
44
44
  const LONG_PREFIX = "--";
45
+ const SHORT_FLAG_PATTERN = /^-([A-Za-z])$/u;
45
46
 
46
47
  export function parseArgs(args: readonly string[]): ParsedArgs {
47
48
  const positional: string[] = [];
@@ -57,6 +58,15 @@ export function parseArgs(args: readonly string[]): ParsedArgs {
57
58
  continue;
58
59
  }
59
60
 
61
+ // Short flag: single dash + single letter (e.g. -g).
62
+ const shortMatch = SHORT_FLAG_PATTERN.exec(token);
63
+ if (shortMatch) {
64
+ const key: string = shortMatch[1]!;
65
+ flags.add(key);
66
+ providedOptions.push(key);
67
+ continue;
68
+ }
69
+
60
70
  if (!token.startsWith(LONG_PREFIX)) {
61
71
  positional.push(token);
62
72
  continue;
@@ -30,7 +30,8 @@ const ROOT_HELP = [
30
30
  " migrate Migration status and rollback commands",
31
31
  " session One-call agent orientation (diagnostics + sync + next task)",
32
32
  " sync Cross-branch sync commands",
33
- " skills Project-local skill install/update/link",
33
+ " skills Skill install/update/link (local and global)",
34
+ " update Alias for skills update",
34
35
  ].join("\n");
35
36
 
36
37
  const INIT_HELP = [
@@ -385,31 +386,46 @@ const SESSION_HELP = [
385
386
  const SKILLS_HELP = [
386
387
  "Usage:",
387
388
  " trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]",
389
+ " trekoon skills install -g|--global [--editor opencode|claude|pi]",
388
390
  " trekoon skills update",
389
391
  "",
390
392
  "Purpose:",
391
- " Install or refresh the project-local Trekoon skill asset.",
393
+ " Install or refresh the Trekoon skill asset locally or globally.",
392
394
  "",
393
- "Install behavior:",
394
- " - Always installs canonical file to:",
395
- " <cwd>/.agents/skills/trekoon/SKILL.md",
395
+ "Local install behavior (default):",
396
+ " - Creates a directory symlink at <cwd>/.agents/skills/trekoon pointing to",
397
+ " the bundled package source, so the skill always matches the installed version.",
396
398
  " - Use --link to also create an editor symlink named 'trekoon'.",
397
399
  " - --editor is required when --link is used (opencode|claude|pi).",
398
400
  " - --to overrides the symlink root for --link only.",
399
401
  " - Without --allow-outside-repo, link targets must resolve inside repo.",
400
402
  " - --allow-outside-repo requires --link and disables that boundary check.",
401
403
  "",
404
+ "Global install behavior (-g|--global):",
405
+ " - Creates a global anchor symlink at ~/.agents/skills/trekoon pointing to",
406
+ " the bundled package source.",
407
+ " - Creates per-editor symlinks under each editor's global skills directory",
408
+ " (~/.claude/skills/, ~/.config/opencode/skills/, ~/.pi/skills/).",
409
+ " - Use --editor to install for a single editor only.",
410
+ "",
402
411
  "Update behavior:",
403
- " - Refreshes canonical SKILL file in the install path above.",
404
- " - Auto-creates or refreshes editor symlinks when editor config dir exists.",
405
- " - Skips editors with no config dir or conflicting paths.",
412
+ " - Probes and repairs both global and local anchor/editor symlinks.",
413
+ " - Reports per-entry status: ok, repointed, created, migrated, skipped.",
414
+ " - Skips entries that are not installed; creates local editor links when",
415
+ " the editor config dir exists.",
416
+ "",
417
+ "Alias:",
418
+ " trekoon update → trekoon skills update",
406
419
  "",
407
420
  "Examples:",
408
421
  " trekoon skills install",
422
+ " trekoon skills install -g",
423
+ " trekoon skills install --global --editor claude",
409
424
  " trekoon skills install --link --editor opencode",
410
425
  " trekoon skills install --link --editor claude --to .claude/skills",
411
426
  " trekoon skills install --link --editor pi --to ../shared/skills --allow-outside-repo",
412
427
  " trekoon skills update",
428
+ " trekoon update",
413
429
  ].join("\n");
414
430
 
415
431
  const COMMAND_HELP: Record<string, string> = {
@@ -426,6 +442,7 @@ const COMMAND_HELP: Record<string, string> = {
426
442
  migrate: MIGRATE_HELP,
427
443
  sync: SYNC_HELP,
428
444
  skills: SKILLS_HELP,
445
+ update: "Usage: trekoon update [--json|--toon]\n\nAlias for: trekoon skills update\n\nProbes and repairs all installed global and local skill symlinks.",
429
446
  help: "Usage: trekoon help [command] [--json|--toon]",
430
447
  };
431
448