local-kanban 1.14.0 → 1.15.0

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/CHANGELOG.md CHANGED
@@ -8,6 +8,34 @@ version it goes out as and opens an empty one again. Released sections are histo
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.15.0]
12
+
13
+ ### Added
14
+
15
+ - **A column can be sorted by task number.** Every column with more than one card has a sort button
16
+ in its header: by priority and your own order (as before), by number with the newest first, or by
17
+ number with the oldest first. Pinned cards stay on top either way. The choice is remembered per
18
+ board and column in this browser and changes only what you see: the order `kb take` follows stays
19
+ the same. While a column is sorted by number its cards cannot be reordered by hand, and a card
20
+ dragged in from another column lands where its number puts it.
21
+
22
+ ### Fixed
23
+
24
+ - **`kb link` refuses a link type it does not know, whatever the word.** `kb link A B constructor` (or
25
+ `toString`) was taken for a valid type, because every object answers for those names, and the
26
+ board quietly made the link `related`. Now it is refused with exit code 2 before anything is
27
+ written, like any other unknown word.
28
+
29
+ ## [1.14.1]
30
+
31
+ ### Changed
32
+
33
+ - **An `ask` task goes back to To do when the discussion ends without the work.** The skill told
34
+ Claude to ask and wait, but not what comes after: a task that was only discussed went to Review,
35
+ where a bare proposal reads as delivered, or stayed in Prep, a column the human cannot move a card
36
+ out of. Now Claude leaves the outcome of the discussion as a note and moves the card back to To do;
37
+ Review is only for work that was actually done.
38
+
11
39
  ## [1.14.0]
12
40
 
13
41
  ### Added
package/bin/kb CHANGED
@@ -309,10 +309,12 @@ const commands = {
309
309
 
310
310
  // kb link A B [child|parent|related] — `child`: B becomes a sub-task of A, `parent`: B becomes
311
311
  // A's parent. Without a third word the link is plain `related`, as it always was. A pair holds
312
- // one link, so linking again with another word re-types it.
312
+ // one link, so linking again with another word re-types it. The word is looked up as an OWN key:
313
+ // `REL[rel]` also answers for `constructor`, `toString` or `__proto__` (they come from the
314
+ // prototype), and such a word went to the board, which quietly made the link `related`.
313
315
  async link() {
314
316
  const [id, other, rel = 'related'] = args;
315
- if (!id || !other || !REL[rel]) die('kb link <key> <key> [child|parent|related] (child: the second task becomes a sub-task of the first)');
317
+ if (!id || !other || !Object.hasOwn(REL, rel)) die('kb link <key> <key> [child|parent|related] (child: the second task becomes a sub-task of the first)');
316
318
  const links = await api('POST', `/api/tasks/${id}/links`, { key: other, rel });
317
319
  console.log(linksLine(id, links));
318
320
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
@@ -1,8 +1,9 @@
1
1
 
2
2
  import { $, ALL, ALL_STATUSES, api, DONE_LIMIT, esc, ic, LABEL_COLORS, LABEL_SELECTABLE, noteUnsaved, popoverPlacement, PRI_ICON, PRI_LEVELS, seg, state, tr } from './core.js';
3
3
  import { markKbCursor, openCardMenu, openDrawer, openDrawerNew, setKbCursor, statusOptionList } from './drawer.js';
4
+ import { columnComparator, DEFAULT_SORT, orderColumn, slotBefore, sortMode } from './order.js';
4
5
  import { getSetting, plural } from './settings.js';
5
- import { styledAlert, styledConfirm } from './sidebar.js';
6
+ import { popLayer, pushLayer, styledAlert, styledConfirm } from './sidebar.js';
6
7
  import { copyBacklogPrompt, copyTodoPrompt, refresh } from './sse.js';
7
8
 
8
9
  function matchesSearch(t) {
@@ -38,19 +39,22 @@ export function renderBoard() {
38
39
  const columns = ALL_STATUSES.map(([k]) => k)
39
40
  .filter((k) => k !== 'cancelled' || state.tasks.some((t) => t.status === 'cancelled'));
40
41
  for (const key of columns) {
41
- let tasks = state.tasks
42
- .filter((t) => t.status === key && matchesSearch(t))
43
- .sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.priority - a.priority || a.position - b.position || a.id - b.id);
44
- if (key === 'done' || key === 'cancelled') {
45
- tasks = tasks.sort((a, b) => b.id - a.id).slice(0, DONE_LIMIT)
46
- .sort((a, b) => b.priority - a.priority || b.id - a.id);
47
- }
42
+ const mode = colSort(key);
43
+ const tasks = orderColumn(state.tasks.filter((t) => t.status === key && matchesSearch(t)), mode, colOrderOpts(key));
48
44
 
49
45
  const col = document.createElement('div');
50
46
  col.className = 'col';
51
47
  const head = document.createElement('div');
52
48
  head.className = 'col-head';
53
49
  head.innerHTML = `<span class="dot s-${key}"></span>${labels[key]} <span class="n">${tasks.length}</span>`;
50
+ if (tasks.length > 1 || mode !== DEFAULT_SORT) {
51
+ const sort = document.createElement('button');
52
+ sort.className = `btn-icon col-sort${mode !== DEFAULT_SORT ? ' on' : ''}`;
53
+ sort.innerHTML = ic('sort', 13);
54
+ sort.title = mode === DEFAULT_SORT ? tr('Sort the column') : tr(SORT_LABELS[mode]);
55
+ sort.onclick = (e) => { e.stopPropagation(); openColSortMenu(sort, key); };
56
+ head.appendChild(sort);
57
+ }
54
58
  if (CREATABLE.has(key)) {
55
59
  const add = document.createElement('button');
56
60
  add.className = 'btn-icon col-add';
@@ -88,7 +92,7 @@ export function renderBoard() {
88
92
  e.preventDefault();
89
93
  body.classList.add('dragover');
90
94
  if (dragId == null) return;
91
- const after = dragAfterCard(body, e.clientY, dragId);
95
+ const after = colSort(key) === DEFAULT_SORT ? dragAfterCard(body, e.clientY, dragId) : sortedSlotCard(body, key, dt);
92
96
  if (dropPh.parentNode !== body || dropPh.nextSibling !== after) body.insertBefore(dropPh, after);
93
97
  };
94
98
  body.ondragleave = (e) => { if (!body.contains(e.relatedTarget)) body.classList.remove('dragover'); };
@@ -470,6 +474,66 @@ export async function multiAction(act, btn) {
470
474
  }
471
475
  }
472
476
 
477
+ const SORT_KEY = 'kb.ui.colSort';
478
+ const SORT_LABELS = {
479
+ manual: 'By priority, then by hand',
480
+ 'num-desc': 'By number, newest first',
481
+ 'num-asc': 'By number, oldest first',
482
+ };
483
+ function readColSorts() {
484
+ try {
485
+ const v = JSON.parse(localStorage.getItem(SORT_KEY));
486
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
487
+ } catch { return {}; }
488
+ }
489
+ export function colSort(status) { return sortMode(readColSorts()[`${state.slug}:${status}`]); }
490
+ function setColSort(status, mode) {
491
+ const all = readColSorts();
492
+ const k = `${state.slug}:${status}`;
493
+ if (sortMode(mode) === DEFAULT_SORT) delete all[k]; else all[k] = mode;
494
+ try { localStorage.setItem(SORT_KEY, JSON.stringify(all)); } catch { }
495
+ renderBoard();
496
+ }
497
+ function colOrderOpts(status) {
498
+ return { mixed: state.slug === ALL, closed: status === 'done' || status === 'cancelled', limit: DONE_LIMIT };
499
+ }
500
+ function sortedSlotCard(body, status, t) {
501
+ const id = slotBefore(state.tasks.filter((x) => x.status === status && matchesSearch(x)), t, colSort(status), colOrderOpts(status));
502
+ return id == null ? null : body.querySelector(`.card[data-id="${id}"]`);
503
+ }
504
+
505
+ function closeColSortMenu() {
506
+ popLayer('col-sort-menu');
507
+ $('col-sort-menu')?.remove();
508
+ document.removeEventListener('click', colSortOutside);
509
+ }
510
+ function colSortOutside(e) { if (!e.target.closest('#col-sort-menu')) closeColSortMenu(); }
511
+ function openColSortMenu(anchor, status) {
512
+ const was = $('col-sort-menu')?.dataset.status;
513
+ closeColSortMenu();
514
+ if (was === status) return;
515
+ pushLayer('col-sort-menu', closeColSortMenu);
516
+ const menu = document.createElement('div');
517
+ menu.className = 'drawer-menu-pop col-sort-menu';
518
+ menu.id = 'col-sort-menu';
519
+ menu.dataset.status = status;
520
+ const cur = colSort(status);
521
+ menu.innerHTML = Object.entries(SORT_LABELS).map(([v, label]) =>
522
+ `<div class="dm-item${v === cur ? ' on' : ''}" data-v="${v}">${esc(tr(label))}${v === cur ? '<span class="dm-chev">✓</span>' : ''}</div>`).join('')
523
+ + `<div class="dm-sep"></div><div class="dm-hint">${esc(tr('Sorted by number, cards cannot be reordered by hand'))}</div>`;
524
+ document.body.appendChild(menu);
525
+ const r = anchor.getBoundingClientRect();
526
+ let left = r.left;
527
+ if (left + menu.offsetWidth > window.innerWidth - 8) left = window.innerWidth - menu.offsetWidth - 8;
528
+ menu.style.left = `${Math.max(8, left)}px`;
529
+ menu.style.top = `${r.bottom + 4}px`;
530
+ menu.querySelectorAll('.dm-item').forEach((el) => {
531
+ el.onclick = () => { closeColSortMenu(); if (el.dataset.v !== cur) setColSort(status, el.dataset.v); };
532
+ });
533
+ requestAnimationFrame(() => menu.classList.add('open'));
534
+ document.addEventListener('click', colSortOutside);
535
+ }
536
+
473
537
  function dragAfterCard(body, y, draggedId) {
474
538
  const cards = [...body.querySelectorAll('.card')].filter((c) => Number(c.dataset.id) !== draggedId);
475
539
  return cards.find((c) => y < c.getBoundingClientRect().top + c.offsetHeight / 2) || null;
@@ -587,16 +651,19 @@ async function onDrop(e, status, body) {
587
651
  if (dragGroupIds && dragGroupIds.length > 1) return onDropGroup(e, status, body);
588
652
  if (moveBlocked(t.status, status, { manual: taskIsNoclaude(t) })) return;
589
653
 
590
- const cards = [...body.querySelectorAll('.card')].filter((c) => Number(c.dataset.id) !== id);
591
- const y = e.clientY;
592
- let idx = cards.findIndex((c) => y < c.getBoundingClientRect().top + c.offsetHeight / 2);
593
- if (idx === -1) idx = cards.length;
594
- const colTasks = state.tasks
595
- .filter((x) => x.status === status && x.id !== id)
596
- .sort((a, b) => b.priority - a.priority || a.position - b.position || a.id - b.id);
597
- const prev = colTasks[idx - 1]?.position ?? 0;
598
- const next = colTasks[idx]?.position ?? prev + 2;
599
- const position = (prev + next) / 2;
654
+ const fixedOrder = colSort(status) !== DEFAULT_SORT;
655
+ if (fixedOrder && t.status === status) { dropPh.remove(); return; }
656
+ let patch = { status };
657
+ if (!fixedOrder) {
658
+ const cards = [...body.querySelectorAll('.card')].filter((c) => Number(c.dataset.id) !== id);
659
+ const y = e.clientY;
660
+ let idx = cards.findIndex((c) => y < c.getBoundingClientRect().top + c.offsetHeight / 2);
661
+ if (idx === -1) idx = cards.length;
662
+ const colTasks = state.tasks.filter((x) => x.status === status && x.id !== id).sort(columnComparator(DEFAULT_SORT));
663
+ const prev = colTasks[idx - 1]?.position ?? 0;
664
+ const next = colTasks[idx]?.position ?? prev + 2;
665
+ patch = { status, position: (prev + next) / 2 };
666
+ }
600
667
 
601
668
  const card = $('board').querySelector(`.card[data-id="${id}"]`);
602
669
  if (card) {
@@ -607,10 +674,10 @@ async function onDrop(e, status, body) {
607
674
  }
608
675
  dropPh.remove();
609
676
  try {
610
- await api('PATCH', `/api/tasks/${seg(id)}`, { status, position });
677
+ await api('PATCH', `/api/tasks/${seg(id)}`, patch);
611
678
  } catch (e) {
612
679
  renderBoard();
613
- if (e?.offline) noteUnsaved(() => api('PATCH', `/api/tasks/${seg(id)}`, { status, position }).then(refresh));
680
+ if (e?.offline) noteUnsaved(() => api('PATCH', `/api/tasks/${seg(id)}`, patch).then(refresh));
614
681
  return;
615
682
  }
616
683
  await refresh();
@@ -637,16 +704,16 @@ async function onDropGroup(e, status, body) {
637
704
  const y = e.clientY;
638
705
  let idx = cards.findIndex((c) => y < c.getBoundingClientRect().top + c.offsetHeight / 2);
639
706
  if (idx === -1) idx = cards.length;
640
- const colTasks = state.tasks
641
- .filter((x) => x.status === status && !draggedSet.has(x.id))
642
- .sort((a, b) => b.priority - a.priority || a.position - b.position || a.id - b.id);
707
+ const colTasks = state.tasks.filter((x) => x.status === status && !draggedSet.has(x.id)).sort(columnComparator(DEFAULT_SORT));
643
708
  const prev = colTasks[idx - 1]?.position ?? 0;
644
709
  const next = colTasks[idx]?.position ?? prev + movable.length + 1;
645
710
  const step = (next - prev) / (movable.length + 1);
711
+ const fixedOrder = colSort(status) !== DEFAULT_SORT;
646
712
 
647
713
  dropPh.remove();
648
714
  for (let i = 0; i < movable.length; i++) {
649
- await api('PATCH', `/api/tasks/${seg(movable[i].id)}`, { status, position: prev + step * (i + 1) });
715
+ if (fixedOrder && movable[i].status === status) continue;
716
+ await api('PATCH', `/api/tasks/${seg(movable[i].id)}`, fixedOrder ? { status } : { status, position: prev + step * (i + 1) });
650
717
  }
651
718
  clearSelection();
652
719
  await refresh();
package/public/js/core.js CHANGED
@@ -13,6 +13,7 @@ const ICONS = {
13
13
  warn: 'M8 2.6 14.4 13.4H1.6L8 2.6ZM8 6.6v3M8 11.4v.1',
14
14
  trash: 'M2.8 4.4h10.4M6.4 4.4V2.9h3.2v1.5M4.2 4.4l.6 8.7a1 1 0 0 0 1 .9h4.4a1 1 0 0 0 1-.9l.6-8.7M6.7 6.9v4.6M9.3 6.9v4.6',
15
15
  plus: 'M8 3.4v9.2M3.4 8h9.2',
16
+ sort: 'M4.8 2.8v10.4M4.8 13.2 2.4 10.8M4.8 13.2l2.4-2.4M11.2 13.2V2.8M11.2 2.8 8.8 5.2M11.2 2.8l2.4 2.4',
16
17
  download: 'M8 2.6v7.8M5 7.4 8 10.4l3-3M2.8 12.6v.8a1 1 0 0 0 1 1h8.4a1 1 0 0 0 1-1v-.8',
17
18
  duplicate: 'M3.4 10.6H2.6a1 1 0 0 1-1-1V2.6a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v.8M6.4 5.4h7a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z',
18
19
  person: 'M8 8.4a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2ZM3.2 14a4.8 4.8 0 0 1 9.6 0',
@@ -111,6 +112,9 @@ const I18N_RU = {
111
112
  'built-in section — cannot be deleted': 'служебный раздел — удалить нельзя',
112
113
  'built-in section — the name cannot be changed': 'служебный раздел — имя менять нельзя',
113
114
  'Busiest around': 'Жарче всего около',
115
+ 'By number, newest first': 'По номеру: сначала новые',
116
+ 'By number, oldest first': 'По номеру: сначала старые',
117
+ 'By priority, then by hand': 'По приоритету, затем вручную',
114
118
  'Calendar': 'Календарь',
115
119
  'Cancel': 'Отмена',
116
120
  'Cancel task': 'Отменить задачу',
@@ -582,6 +586,8 @@ const I18N_RU = {
582
586
  'Setup via Claude': 'Настройка через Claude',
583
587
  'Skip': 'Пропустить',
584
588
  'Skip — work locally': 'Пропустить — работать локально',
589
+ 'Sort the column': 'Сортировка колонки',
590
+ 'Sorted by number, cards cannot be reordered by hand': 'При сортировке по номеру карточки вручную не переставить',
585
591
  'Sound diagnostics': 'Диагностика звука',
586
592
  'Sound for Done': 'Звук на «Готово»',
587
593
  'Sound for Review': 'Звук на «Проверяю»',
@@ -1,6 +1,7 @@
1
1
 
2
- import { WORKING, buildSelect, canMoveTo, clearSelection, labelChip, moveBlocked, scheduleDrawLinks, selVal, selectedIds, setScrollToNewCardId, taskIsNoclaude } from './board.js';
2
+ import { WORKING, buildSelect, canMoveTo, clearSelection, colSort, labelChip, moveBlocked, scheduleDrawLinks, selVal, selectedIds, setScrollToNewCardId, taskIsNoclaude } from './board.js';
3
3
  import { $, ALL, ALL_STATUSES, DASH, LABEL_COLORS, LABEL_SELECTABLE, PRI_LEVELS, SETTINGS, TITLE_LABEL_WORDS, api, apiBlob, esc, ghSyncOn, ic, noteUnsaved, seg, state, tr } from './core.js';
4
+ import { columnComparator, DEFAULT_SORT } from './order.js';
4
5
  import { getSetting } from './settings.js';
5
6
  import { currentProject, overlayLayer, popLayer, pushLayer, selectProject, styledAlert, styledConfirm } from './sidebar.js';
6
7
  import { autoGrow, copyText, refresh, relTime, renderTimeline } from './sse.js';
@@ -393,12 +394,12 @@ function syncDrawerToCursor() {
393
394
  if (t && t.key !== state.drawerKey) openDrawer(t.key);
394
395
  }, 150);
395
396
  }
396
- const kbBoardSort = (a, b) => b.priority - a.priority || a.position - b.position || a.id - b.id;
397
397
  export async function kbMoveCard(dr, dc) {
398
398
  const t = state.tasks.find((x) => x.id === state.kbCursor);
399
399
  if (!t) return;
400
400
  if (dr) {
401
- const sorted = state.tasks.filter((x) => x.status === t.status).sort(kbBoardSort);
401
+ if (colSort(t.status) !== DEFAULT_SORT) return;
402
+ const sorted = state.tasks.filter((x) => x.status === t.status).sort(columnComparator(DEFAULT_SORT));
402
403
  const ri = sorted.findIndex((x) => x.id === t.id);
403
404
  const ti = Math.max(0, Math.min(sorted.length - 1, ri + dr));
404
405
  if (ti === ri) return;
package/public/js/init.js CHANGED
@@ -58,7 +58,7 @@ $('search').addEventListener('input', (e) => {
58
58
  window.addEventListener('resize', scheduleDrawLinks);
59
59
  $('board').addEventListener('scroll', scheduleDrawLinks, true);
60
60
 
61
- const POPUP_SELECTOR = '.modal-overlay, #card-menu, #drawer-menu-pop, #return-pop, .lbl-picker, #status-picker, .lightbox, .kbsel.open';
61
+ const POPUP_SELECTOR = '.modal-overlay, #card-menu, #col-sort-menu, #drawer-menu-pop, #return-pop, .lbl-picker, #status-picker, .lightbox, .kbsel.open';
62
62
 
63
63
  function escClosePopup() {
64
64
  if (document.querySelector('.kbsel.open')) { closeOpenSelect(); return true; }
@@ -0,0 +1,37 @@
1
+
2
+ export const SORT_MODES = ['manual', 'num-desc', 'num-asc'];
3
+ export const DEFAULT_SORT = 'manual';
4
+
5
+ export function sortMode(v) { return SORT_MODES.includes(v) ? v : DEFAULT_SORT; }
6
+
7
+ export function taskNumber(t) {
8
+ const n = Number(String(t.key || '').split('-').pop());
9
+ return Number.isFinite(n) ? n : 0;
10
+ }
11
+
12
+ const pinnedFirst = (a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0);
13
+
14
+ export function columnComparator(mode, { mixed = false, closed = false } = {}) {
15
+ const m = sortMode(mode);
16
+ const pin = closed ? () => 0 : pinnedFirst;
17
+ if (m === DEFAULT_SORT) {
18
+ return closed
19
+ ? (a, b) => b.priority - a.priority || b.id - a.id
20
+ : (a, b) => pin(a, b) || b.priority - a.priority || a.position - b.position || a.id - b.id;
21
+ }
22
+ const num = mixed ? (t) => t.id : taskNumber;
23
+ const dir = m === 'num-asc' ? 1 : -1;
24
+ return (a, b) => pin(a, b) || dir * (num(a) - num(b)) || dir * (a.id - b.id);
25
+ }
26
+
27
+ export function orderColumn(tasks, mode, { mixed = false, closed = false, limit = Infinity } = {}) {
28
+ let list = [...tasks];
29
+ if (closed) list = list.sort((a, b) => b.id - a.id).slice(0, limit);
30
+ return list.sort(columnComparator(mode, { mixed, closed }));
31
+ }
32
+
33
+ export function slotBefore(tasks, task, mode, opts = {}) {
34
+ const cmp = columnComparator(mode, opts);
35
+ const next = orderColumn(tasks.filter((x) => x.id !== task.id), mode, opts).find((x) => cmp(task, x) < 0);
36
+ return next ? next.id : null;
37
+ }
package/public/style.css CHANGED
@@ -229,7 +229,14 @@ html.booted #horizon, html.booted #calendar, html.booted #chaos { animation: boo
229
229
  }
230
230
  .col-head .dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
231
231
  .col-head .n { color: var(--muted); font-weight: 400; }
232
- .col-add { margin-left: auto; }
232
+ .col-head .n + .btn-icon { margin-left: auto; }
233
+ .col-sort { font-size: 12px; opacity: .65; }
234
+ .col-sort:hover { opacity: 1; }
235
+ .col-sort.on { opacity: 1; color: var(--accent); }
236
+ .col-sort-menu { transform-origin: top left; min-width: 214px; }
237
+ .col-sort-menu .dm-item.on { color: var(--accent); }
238
+ .col-sort-menu .dm-chev { color: var(--accent); }
239
+ .dm-hint { width: 0; min-width: 100%; box-sizing: border-box; padding: 3px 10px 6px; font-size: 11.5px; line-height: 1.35; color: var(--muted); }
233
240
  .col-copy { font-size: 12px; opacity: .65; }
234
241
  .col-copy:hover { opacity: 1; }
235
242
  .col-copy.copied { opacity: 1; color: var(--done); font-size: 14px; }
@@ -148,7 +148,8 @@ for each. Use `kb take X` only when asked to "work through project X" without sp
148
148
  <n>`).** Before review every item is either ticked or named in the note as not done — the human
149
149
  accepts the task by those boxes, and an unticked done item reads as undone.
150
150
  6. Stuck, or the decision is the human's → `kb block <key> "reason"` and move on.
151
- 7. At the end, a one-line summary: how many in review, how many blocked.
151
+ 7. At the end, a one-line summary: how many in review, how many went back to To do after a
152
+ discussion (`ask`), how many blocked.
152
153
 
153
154
  ## Grooming the backlog
154
155
 
@@ -178,6 +179,15 @@ One line of summary at the end.
178
179
  study the task in `prep`, then ask your clarifying questions in chat — how you understood the
179
180
  task, what you intend to change and where — EVEN IF everything looks obvious, and WAIT for an
180
181
  answer. Do not move to `doing` or write code until the human replies.
182
+ **How an `ask` task ends.** Agreed and done → the normal loop up to `review`. The turn passes to
183
+ the human with the work NOT done — you hand it back with questions, you proposed options and the
184
+ choice is theirs, they postponed it → `kb note <key> "…"` with the outcome of the discussion (what
185
+ you asked, what the human answered, what was agreed, what is still open) and **`kb mv <key>
186
+ todo`**; when they answer in chat, `kb mv <key> prep` and carry on. Not `review`: that column holds
187
+ finished work, and a bare proposal there reads as delivered. Not left in `prep` either: only Claude
188
+ moves that column, so the human cannot pull the card out, and your turn with the questions may be
189
+ the last one in the session. The note is mandatory — the chat does not survive between sessions,
190
+ and the next one would start the discussion from scratch.
181
191
  - **You found an unrelated problem along the way** → do not fix it silently. Create a task
182
192
  (`kb add <proj> "..." -d "..."`) and ask in chat: do it now, defer it, or is it for the human.
183
193
  - **Related tasks solved together** → take both at once (both in doing), fix them in one pass and
@@ -185,7 +195,9 @@ One line of summary at the end.
185
195
  (status)`, `↓ subtask …`, `↔ related …` — look at them before you start: a linked task in To do
186
196
  is usually meant to go together with this one, and a parent explains why the task exists.
187
197
  - **A discussion or question task** (no code): study it in `prep`, put the full answer in
188
- `kb note <key> "..."`, then `kb mv <key> review`. You write no code and do not move it to done.
198
+ `kb note <key> "..."`, then `kb mv <key> review`. **With the `ask` label it goes back to `todo`
199
+ instead** (see above): there the discussion is a step before the work, not the work itself. You
200
+ write no code and do not move it to done.
189
201
  - A "check whether the service is alive" task → `kb svc <proj>` (one line per service, no ssh).
190
202
  - Work time is measured for you: doing/deploy runs the timer, review/done stops it.
191
203
  - Verifying the result: text and config changes — curl/grep (cheap). Compiled projects — **a build**