trekoon 0.3.0 → 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.
Files changed (43) hide show
  1. package/.agents/skills/trekoon/SKILL.md +274 -26
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +213 -0
  3. package/.agents/skills/trekoon/reference/execution.md +210 -0
  4. package/.agents/skills/trekoon/reference/planning.md +244 -0
  5. package/README.md +24 -10
  6. package/docs/ai-agents.md +108 -30
  7. package/docs/commands.md +81 -5
  8. package/docs/machine-contracts.md +120 -0
  9. package/docs/plans/r1-unified-skill-rewrite.md +290 -0
  10. package/docs/plans/r10-suggest-command-skill-integration.md +152 -0
  11. package/docs/plans/r9-task-done-diff-skill-integration.md +113 -0
  12. package/docs/quickstart.md +31 -0
  13. package/package.json +2 -2
  14. package/src/board/assets/app.js +5 -0
  15. package/src/board/assets/components/EpicsOverview.js +13 -0
  16. package/src/board/assets/components/Workspace.js +27 -12
  17. package/src/board/assets/components/helpers.js +3 -2
  18. package/src/board/assets/runtime/delegation.js +69 -1
  19. package/src/board/assets/state/actions.js +27 -1
  20. package/src/board/assets/state/store.js +37 -8
  21. package/src/board/assets/state/utils.js +42 -0
  22. package/src/board/assets/styles/board.css +68 -0
  23. package/src/board/routes.ts +2 -0
  24. package/src/commands/epic.ts +74 -3
  25. package/src/commands/session.ts +7 -75
  26. package/src/commands/skills.ts +39 -32
  27. package/src/commands/subtask.ts +7 -5
  28. package/src/commands/suggest.ts +283 -0
  29. package/src/commands/sync-helpers.ts +75 -0
  30. package/src/commands/task-readiness.ts +8 -20
  31. package/src/commands/task.ts +59 -3
  32. package/src/domain/mutation-service.ts +69 -42
  33. package/src/domain/tracker-domain.ts +151 -22
  34. package/src/domain/types.ts +12 -0
  35. package/src/index.ts +1 -1
  36. package/src/io/output.ts +4 -2
  37. package/src/runtime/cli-shell.ts +26 -3
  38. package/src/runtime/command-types.ts +1 -1
  39. package/src/storage/database.ts +43 -1
  40. package/src/storage/events-retention.ts +57 -8
  41. package/src/storage/migrations.ts +58 -3
  42. package/src/sync/service.ts +101 -24
  43. package/src/sync/types.ts +1 -0
@@ -20,8 +20,8 @@ import {
20
20
  STATUS_LABELS,
21
21
  STATUS_ORDER,
22
22
  } from "./helpers.js";
23
- import { orderEpicsNewestFirst } from "../state/store.js";
24
- import { VIEW_MODES } from "../state/utils.js";
23
+ import { DEFAULT_STATUS_FILTER, orderEpicsNewestFirst } from "../state/store.js";
24
+ import { getSelectableStatuses, VIEW_MODES } from "../state/utils.js";
25
25
 
26
26
  // ---------------------------------------------------------------------------
27
27
  // Workspace header
@@ -58,6 +58,9 @@ function renderWorkspaceHeader(props) {
58
58
  visibleTasks,
59
59
  } = props;
60
60
 
61
+ const taskStatusFilter = store.taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
62
+ const isTaskFilterNonDefault = STATUS_ORDER.some(s => taskStatusFilter[s] !== DEFAULT_STATUS_FILTER[s]);
63
+
61
64
  const description = selectedEpic.description?.trim() || "";
62
65
  const inlineSelect = `${fieldClasses()} !py-1 !px-2 !text-xs !min-h-0 !rounded-xl`;
63
66
  const epicStatusTooltip = "Change this epic's overall status.";
@@ -79,7 +82,7 @@ function renderWorkspaceHeader(props) {
79
82
  <label class="board-wh__control-label">
80
83
  <span class="board-wh__control-text">Epic status</span>
81
84
  <select class="${inlineSelect}" name="status" aria-label="Epic status" title="${escapeHtml(epicStatusTooltip)}" ${store.isMutating ? "disabled" : ""}>
82
- ${STATUS_ORDER.map(s => `<option value="${escapeHtml(s)}" ${selectedEpic.status === s ? 'selected' : ''}>${escapeHtml(STATUS_LABELS[s] ?? s)}</option>`).join('')}
85
+ ${getSelectableStatuses(selectedEpic.status).map(s => `<option value="${escapeHtml(s)}" ${selectedEpic.status === s ? 'selected' : ''}>${escapeHtml(STATUS_LABELS[s] ?? s)}</option>`).join('')}
83
86
  </select>
84
87
  </label>
85
88
  </form>
@@ -114,6 +117,14 @@ function renderWorkspaceHeader(props) {
114
117
  <span class="${neutralChipClasses()}">${visibleTasks.length} visible</span>
115
118
  ${store.isMutating ? `<span class="${neutralChipClasses()}">Saving\u2026</span>` : ""}
116
119
  </div>
120
+ <div class="board-filter-bar">
121
+ ${STATUS_ORDER.map(status => {
122
+ const active = taskStatusFilter[status] !== false;
123
+ const count = selectedEpic.counts?.[status] ?? 0;
124
+ return `<button type="button" class="board-filter-pill ${active ? 'board-filter-pill--active' : 'board-filter-pill--inactive'} board-filter-pill--${status}" data-toggle-task-status-filter="${status}" aria-pressed="${active}" title="${active ? 'Hide' : 'Show'} ${STATUS_LABELS[status]} tasks">${STATUS_LABELS[status]} (${count})</button>`;
125
+ }).join('')}
126
+ ${isTaskFilterNonDefault ? `<button type="button" class="board-filter-pill board-filter-pill--reset" data-reset-task-filter title="Reset filters to defaults">Reset</button>` : ''}
127
+ </div>
117
128
  <div class="board-wh__actions">
118
129
  <div class="board-wh__action-group">
119
130
  <button type="button" class="board-copy-btn ${isCopied ? "board-copy-btn--active" : ""}" data-copy-epic-id="${escapeHtml(selectedEpic.id)}" aria-label="${escapeHtml(isCopied ? `Copied epic UUID for ${selectedEpic.title}` : copyLabel)}" title="${escapeHtml(isCopied ? "Epic UUID copied." : copyTooltip)}">
@@ -155,9 +166,12 @@ function renderWorkspaceHeader(props) {
155
166
  // ---------------------------------------------------------------------------
156
167
 
157
168
  function renderKanbanColumns(props) {
158
- const { visibleTasks, selectedTaskId, isMutating } = props;
169
+ const { visibleTasks, selectedTaskId, isMutating, taskStatusFilter } = props;
170
+ const filter = taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
159
171
 
160
- const columnsMarkup = STATUS_ORDER.map((status) => {
172
+ const columnsMarkup = STATUS_ORDER
173
+ .filter((status) => filter[status] !== false)
174
+ .map((status) => {
161
175
  const columnTasks = visibleTasks.filter((t) => t.status === status);
162
176
  const columnTitle = readStatusLabel(status);
163
177
  const content = columnTasks.length === 0
@@ -265,19 +279,20 @@ function render(props) {
265
279
  const headerMarkup = renderWorkspaceHeader({
266
280
  selectedEpic,
267
281
  snapshotEpics,
268
- store: {
269
- copyFeedback: store.copyFeedback,
270
- notesPanelOpen: store.notesPanelOpen,
271
- isMutating: store.isMutating,
272
- selectedEpicId: selectedEpic.id,
273
- view: store.view,
282
+ store: {
283
+ copyFeedback: store.copyFeedback,
284
+ notesPanelOpen: store.notesPanelOpen,
285
+ isMutating: store.isMutating,
286
+ selectedEpicId: selectedEpic.id,
287
+ view: store.view,
274
288
  viewModes,
289
+ taskStatusFilter: store.taskStatusFilter,
275
290
  },
276
291
  visibleTasks,
277
292
  });
278
293
 
279
294
  const contentMarkup = store.view === "kanban"
280
- ? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating })
295
+ ? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating, taskStatusFilter: store.taskStatusFilter })
281
296
  : renderListView({ visibleTasks, selectedTaskId });
282
297
 
283
298
  return `
@@ -1,4 +1,4 @@
1
- import { escapeHtml, formatDate, normalizeStatus, STATUS_ORDER } from "../state/utils.js";
1
+ import { escapeHtml, formatDate, getSelectableStatuses, normalizeStatus, STATUS_ORDER } from "../state/utils.js";
2
2
 
3
3
  export { escapeHtml, formatDate, normalizeStatus, STATUS_ORDER };
4
4
 
@@ -123,9 +123,10 @@ export function renderStatusBadge(rawStatus, label = readStatusLabel(rawStatus))
123
123
  }
124
124
 
125
125
  export function renderStatusSelect(name, selectedStatus, disabled = false) {
126
+ const statuses = getSelectableStatuses(selectedStatus);
126
127
  return `
127
128
  <select class="${fieldClasses()}" name="${escapeHtml(name)}" ${disabled ? "disabled" : ""}>
128
- ${STATUS_ORDER.map((status) => `
129
+ ${statuses.map((status) => `
129
130
  <option value="${escapeHtml(status)}" ${selectedStatus === status ? "selected" : ""}>${escapeHtml(STATUS_LABELS[status] ?? status)}</option>
130
131
  `).join("")}
131
132
  </select>
@@ -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
+ }
@@ -229,6 +229,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
229
229
  title: readOptionalString(body, "title"),
230
230
  description: readOptionalString(body, "description"),
231
231
  status: readOptionalString(body, "status"),
232
+ owner: readOptionalString(body, "owner"),
232
233
  });
233
234
  return buildMutationResponse(domain, { task });
234
235
  }
@@ -240,6 +241,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
240
241
  title: readOptionalString(body, "title"),
241
242
  description: readOptionalString(body, "description"),
242
243
  status: readOptionalString(body, "status"),
244
+ owner: readOptionalString(body, "owner"),
243
245
  });
244
246
  return buildMutationResponse(domain, { subtask });
245
247
  }
@@ -18,6 +18,7 @@ import {
18
18
  suggestOptions,
19
19
  } from "./arg-parser";
20
20
  import { unexpectedFailureResult } from "./error-utils";
21
+ import { buildTaskReadiness } from "./task-readiness";
21
22
 
22
23
  import { MutationService } from "../domain/mutation-service";
23
24
  import { TrackerDomain } from "../domain/tracker-domain";
@@ -44,7 +45,7 @@ function formatEpic(epic: EpicRecord): string {
44
45
  const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
45
46
  const LIST_VIEW_MODES = ["table", "compact"] as const;
46
47
  const DEFAULT_LIST_LIMIT = 10;
47
- const DEFAULT_OPEN_STATUSES = ["in_progress", "in-progress", "todo"] as const;
48
+ const DEFAULT_OPEN_STATUSES = ["in_progress", "todo"] as const;
48
49
  const CREATE_OPTIONS = ["title", "t", "description", "d", "status", "s", "task", "subtask", "dep"] as const;
49
50
  const LIST_OPTIONS = ["status", "s", "limit", "l", "cursor", "all", "view"] as const;
50
51
  const SHOW_OPTIONS = ["view", "all"] as const;
@@ -115,7 +116,7 @@ function formatSearchHuman(matches: readonly SearchEntityMatch[], emptyMessage:
115
116
  }
116
117
 
117
118
  function getStatusPriority(status: string): number {
118
- if (status === "in_progress" || status === "in-progress") {
119
+ if (status === "in_progress") {
119
120
  return 0;
120
121
  }
121
122
 
@@ -1391,6 +1392,76 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
1391
1392
  data: { epic },
1392
1393
  });
1393
1394
  }
1395
+ case "progress": {
1396
+ const epicId: string = parsed.positional[1] ?? "";
1397
+ if (epicId.length === 0) {
1398
+ return failResult({
1399
+ command: "epic.progress",
1400
+ human: "Provide an epic id. Usage: trekoon epic progress <epic-id>",
1401
+ data: { code: "invalid_input" },
1402
+ error: {
1403
+ code: "invalid_input",
1404
+ message: "Missing epic id",
1405
+ },
1406
+ });
1407
+ }
1408
+
1409
+ const epic = domain.getEpic(epicId);
1410
+ if (!epic) {
1411
+ return failResult({
1412
+ command: "epic.progress",
1413
+ human: `Epic not found: ${epicId}`,
1414
+ data: { code: "not_found", id: epicId },
1415
+ error: {
1416
+ code: "not_found",
1417
+ message: `Epic not found: ${epicId}`,
1418
+ },
1419
+ });
1420
+ }
1421
+
1422
+ const allTasks = domain.listTasks(epicId);
1423
+ let doneCount = 0;
1424
+ let inProgressCount = 0;
1425
+ let blockedCount = 0;
1426
+ let todoCount = 0;
1427
+ for (const t of allTasks) {
1428
+ if (t.status === "done") doneCount += 1;
1429
+ else if (t.status === "in_progress") inProgressCount += 1;
1430
+ else if (t.status === "blocked") blockedCount += 1;
1431
+ else if (t.status === "todo") todoCount += 1;
1432
+ }
1433
+
1434
+ const readiness = buildTaskReadiness(domain, epicId);
1435
+ const readyCount = readiness.summary.readyCount;
1436
+ const nextCandidate = readiness.candidates[0] ?? null;
1437
+
1438
+ const nextTask = nextCandidate !== null
1439
+ ? { id: nextCandidate.task.id, title: nextCandidate.task.title }
1440
+ : null;
1441
+
1442
+ const total = allTasks.length;
1443
+ let human = `Epic: ${epic.title}\n`;
1444
+ human += `Total: ${total}, Done: ${doneCount}, In Progress: ${inProgressCount}, Blocked: ${blockedCount}, Todo: ${todoCount}, Ready: ${readyCount}`;
1445
+ if (nextTask !== null) {
1446
+ human += `\nNext candidate: ${nextTask.id} | ${nextTask.title}`;
1447
+ }
1448
+
1449
+ return okResult({
1450
+ command: "epic.progress",
1451
+ human,
1452
+ data: {
1453
+ epicId: epic.id,
1454
+ title: epic.title,
1455
+ total,
1456
+ doneCount,
1457
+ inProgressCount,
1458
+ blockedCount,
1459
+ todoCount,
1460
+ readyCount,
1461
+ nextCandidate: nextTask,
1462
+ },
1463
+ });
1464
+ }
1394
1465
  case "delete": {
1395
1466
  const epicId: string = parsed.positional[1] ?? "";
1396
1467
  mutations.deleteEpic(epicId);
@@ -1404,7 +1475,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
1404
1475
  default:
1405
1476
  return failResult({
1406
1477
  command: "epic",
1407
- human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete>",
1478
+ human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress>",
1408
1479
  data: {
1409
1480
  args: context.args,
1410
1481
  },