@goodandready/dsh-kanban 0.2.19 → 0.2.20

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/lib/lifecycle.js CHANGED
@@ -1,34 +1,28 @@
1
- // Признак ожидания по событиям сессии.
2
- //
3
- // Колонки «Блокировано» в согласованном наборе нет, поэтому запрос разрешения
4
- // или вопрос агента НЕ двигают карточку, а поднимают на ней признак «нужен
5
- // человек». Двигать в несуществующую колонку нельзя, а прятать «агент стоит и
6
- // ждёт» — значит терять главное, ради чего смотрят на доску.
7
- //
8
- // Подписка на события ядра живёт в `lib/index.js`; здесь только решение, что
9
- // делать с очередным событием.
1
+ // Session waiting indicators.
2
+ // Permission requests or agent prompts raise a 'waiting for user' flag on card
3
+ // without moving it to a non-existent blocked column.
10
4
 
11
5
  import { waitingFromEvent } from './transitions.js'
12
6
  import { parseProgressDump, formatProgressDump } from './progress-dump.js'
13
7
 
14
8
  /**
15
- * Обработать событие сессии.
9
+ * Process session event.
16
10
  *
17
- * Задача ищется по идентификатору сессии. Сессий, поднятых не с доски,
18
- * большинство — для них здесь ничего не происходит, и это штатный ход, а не
19
- * пропуск.
11
+ * Looks up task by sessionId. Non-kanban sessions are cleanly
12
+ * ignored without side effects.
13
+
20
14
  *
21
- * @returns {{taskId: string, waiting?: boolean}|undefined} что изменилось
15
+ * @returns {{taskId: string, waiting?: boolean}|undefined} mutation report
22
16
  */
23
17
  export function handleSessionEvent({ store, sessionId, session, type, text, error }) {
24
- // Исключаем события дочерних сессий субагентов (#215)
18
+ // Filter out subagent child session events (#215)
25
19
  if (session && (session.origin === 'subagent' || session.parentId !== undefined || session.meta?.origin === 'subagent' || session.isSubagent)) {
26
20
  return undefined
27
21
  }
28
22
  const task = store.findTaskBySession(sessionId)
29
23
  if (task === undefined) return undefined
30
24
 
31
- // Если в ответе пришёл PROGRESSDUMP — обновляем срез в задаче
25
+ // If turn returned PROGRESSDUMP, update task snapshot
32
26
  if (typeof text === 'string' && text.includes('<<<PROGRESSDUMP')) {
33
27
  const parsed = parseProgressDump(text)
34
28
  if (parsed.ok) {
@@ -36,7 +30,7 @@ export function handleSessionEvent({ store, sessionId, session, type, text, erro
36
30
  }
37
31
  }
38
32
 
39
- // Обновляем исход запуска в истории runs
33
+ // Update run outcome in run history
40
34
  if (type === 'turn/end' || type === 'turn/completed') {
41
35
  const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
42
36
  if (latestRun && latestRun.sessionId === sessionId && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
package/lib/markdown.js CHANGED
@@ -1,21 +1,19 @@
1
- // Разбор тела задачи в дерево узлов.
1
+ // Parse task body into node tree.
2
2
  //
3
- // Тело приходит из Gitea и пишет его кто угодно. Поэтому разбор отдаёт ДЕРЕВО,
4
- // а не строку HTML: браузерная половина собирает его элементами React, и
5
- // подстановка чужой разметки становится невозможной по построению, а не по
6
- // бдительности того, кто вспомнит про экранирование.
3
+ // Body originates from Gitea and can contain arbitrary user content.
4
+ // Parser returns a node TREE rather than raw HTML string: frontend renders
5
+ // via React elements, preventing XSS injection structurally.
7
6
  //
8
- // Полноценный markdown здесь не нужен и не будет: заголовки, списки, выделение,
9
- // код и ссылки покрывают почти всякое тело issue. Всё непонятое остаётся
10
- // текстом как есть — это честнее, чем угадывать.
7
+ // Full markdown engine is intentionally omitted: headings, lists, formatting,
8
+ // code blocks and links cover typical issue bodies. Unrecognized text remains as-is.
11
9
 
12
- /** Ссылки открываем только по этим схемам: `javascript:` в теле — тот же обход. */
10
+ /** Allowed link protocols: `javascript:` in body is blocked. */
13
11
  const SAFE_LINK = /^https?:\/\//i
14
12
 
15
13
  /**
16
- * Разобрать тело в список блоков.
14
+ * Parse body into list of blocks.
17
15
  *
18
- * @returns {Array<object>} блоки вида
16
+ * @returns {Array<object>} blocks shaped as
19
17
  * `{kind:'heading', level, spans}`, `{kind:'para', spans}`,
20
18
  * `{kind:'list', ordered, items:[spans]}`, `{kind:'code', text}`,
21
19
  * `{kind:'quote', spans}`, `{kind:'rule'}`
@@ -30,7 +28,7 @@ export function parseBody(text) {
30
28
 
31
29
  if (line.trim() === '') { i += 1; continue }
32
30
 
33
- // Блок кода: всё внутри дословно, включая то, что похоже на разметку.
31
+ // Code block: content preserved verbatim, including markup-like sequences.
34
32
  const fence = /^\s*```(.*)$/.exec(line)
35
33
  if (fence !== null) {
36
34
  const body = []
@@ -77,7 +75,7 @@ export function parseBody(text) {
77
75
  continue
78
76
  }
79
77
 
80
- // Абзац: строки до пустой склеиваются, как это делает markdown.
78
+ // Paragraph: lines up to blank line are merged per markdown semantics.
81
79
  const body = []
82
80
  while (i < lines.length && lines[i].trim() !== ''
83
81
  && !/^\s*(#{1,6})\s+/.test(lines[i])
@@ -95,14 +93,14 @@ export function parseBody(text) {
95
93
  }
96
94
 
97
95
  /**
98
- * Разобрать строку на куски: обычный текст, код, выделение, ссылка.
96
+ * Parse line into inline segments: plain text, code, emphasis, links.
99
97
  *
100
98
  * @returns {Array<{kind: 'text'|'code'|'strong'|'em'|'link', text: string, href?: string}>}
101
99
  */
102
100
  export function parseSpans(text) {
103
101
  const out = []
104
102
  const src = String(text ?? '')
105
- // Код первым: внутри обратных кавычек разметки нет, там всё дословно.
103
+ // Code spans first: inside backticks markup is treated literally.
106
104
  const re = /(`[^`]+`)|(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)/g
107
105
  let at = 0
108
106
  let m = re.exec(src)
@@ -117,8 +115,7 @@ export function parseSpans(text) {
117
115
  const cut = piece.indexOf('](')
118
116
  const label = piece.slice(1, cut)
119
117
  const href = piece.slice(cut + 2, -1)
120
- // Небезопасная схема — не ссылка, а текст. Молча выбрасывать нельзя:
121
- // человек должен видеть, что там было написано.
118
+ // Unsafe protocol rendered as plain text rather than active link.
122
119
  if (isSafeHref(href)) out.push({ kind: 'link', text: label, href })
123
120
  else out.push({ kind: 'text', text: piece })
124
121
  } else if (piece.startsWith('**') || piece.startsWith('__')) {
@@ -136,11 +133,7 @@ export function parseSpans(text) {
136
133
  }
137
134
 
138
135
  /**
139
- * Склеить соседние куски текста.
140
- *
141
- * Разбор дробит строку там, где нашёл разметку и передумал: `[клик](javascript:…)`
142
- * не стал ссылкой, но оставил после себя два куска. Дробление наружу не
143
- * выпускаем — оно не значит ничего, а читателю мешает.
136
+ * Merge adjacent text segments to avoid fragmentation.
144
137
  */
145
138
  function mergeText(spans) {
146
139
  const out = []
@@ -155,7 +148,7 @@ function mergeText(spans) {
155
148
  return out
156
149
  }
157
150
 
158
- /** Безопасна ли ссылка. Наружу — чтобы браузерная половина не решала это заново. */
151
+ /** Whether a link protocol is safe. Exported so frontend does not recompute. */
159
152
  export function isSafeHref(href) {
160
153
  return SAFE_LINK.test(String(href ?? ''))
161
154
  }
package/lib/metrics.js CHANGED
@@ -1,22 +1,22 @@
1
- // Метрики доски: взгляд на журнал переходов, а не новые данные.
1
+ // Board metrics: view over transition history, not new mutable state.
2
2
  //
3
- // Журнал копится с первого дня, но прочитать его можно было только по одной
4
- // задаче. Вопросы «где затык», «сколько живёт задача» и «что лежит месяц» имеют
5
- // ответ в тех же записях — их надо только сложить.
3
+ // History accumulates from day one, but was previously queried per-task.
4
+ // Questions like 'where is bottleneck', 'task lifetime', 'stale tasks'
5
+ // can all be answered from transition records.
6
6
  //
7
- // Ничего не записываем: метрика, которая пишет, перестаёт быть наблюдением и
8
- // становится ещё одним источником правды, расходящимся с журналом.
7
+ // Read-only calculation: metrics do not write state to avoid becoming an
8
+ // unaligned second source of truth.
9
9
 
10
10
  /**
11
- * Сколько времени задача пробыла в каждой колонке.
11
+ * Time spent by task in each column.
12
12
  *
13
- * Переход закрывает предыдущий отрезок и открывает следующий. Последний
14
- * отрезок закрывается «сейчас»: задача всё ещё лежит там, и это время идёт.
13
+ * Transition closes previous time slice and opens next.
14
+ * Final slice closes at present moment.
15
15
  *
16
- * @param {Array<{toCol: string, at: number}>} moves переходы задачи, старые первыми
17
- * @param {number} bornAt когда задача заведена — начало первого отрезка
16
+ * @param {Array<{toCol: string, at: number}>} moves task transitions, oldest first
17
+ * @param {number} bornAt task creation timestamp
18
18
  * @param {number} now
19
- * @returns {Object<string, number>} колонка → миллисекунды
19
+ * @returns {Object<string, number>} column -> milliseconds
20
20
  */
21
21
  export function timeInColumns(moves, bornAt, now) {
22
22
  const rows = (moves ?? [])
@@ -25,8 +25,8 @@ export function timeInColumns(moves, bornAt, now) {
25
25
  .sort((a, b) => a.at - b.at)
26
26
 
27
27
  const out = {}
28
- // До первого перехода задача лежит там, где её завели. Колонку начала берём
29
- // из первого перехода: `fromCol` и есть то, откуда она уехала.
28
+ // Prior to first transition, task resides in initial column.
29
+ // Derived from `fromCol` of first transition.
30
30
  let col = rows.length > 0 && typeof rows[0].fromCol === 'string' ? rows[0].fromCol : 'backlog'
31
31
  let since = typeof bornAt === 'number' ? bornAt : (rows[0]?.at ?? now)
32
32
 
@@ -44,7 +44,7 @@ export function timeInColumns(moves, bornAt, now) {
44
44
  return out
45
45
  }
46
46
 
47
- /** Медиана: половина значений меньше, половина больше. */
47
+ /** Median calculation. */
48
48
  export function median(values) {
49
49
  const rows = (values ?? []).filter((x) => typeof x === 'number').slice().sort((a, b) => a - b)
50
50
  if (rows.length === 0) return 0
@@ -52,7 +52,7 @@ export function median(values) {
52
52
  return rows.length % 2 === 1 ? rows[mid] : Math.round((rows[mid - 1] + rows[mid]) / 2)
53
53
  }
54
54
 
55
- /** Среднее; пустой набор даёт ноль, а не деление на ноль. */
55
+ /** Mean average; empty set returns zero without dividing by zero. */
56
56
  export function mean(values) {
57
57
  const rows = (values ?? []).filter((x) => typeof x === 'number')
58
58
  if (rows.length === 0) return 0
@@ -60,10 +60,10 @@ export function mean(values) {
60
60
  }
61
61
 
62
62
  /**
63
- * Сводка по колонкам: сколько в среднем и по медиане в них лежат.
63
+ * Column summary: average and median time spent in each column.
64
64
  *
65
- * Медиана важнее среднего: одна задача, забытая на полгода, сдвигает среднее
66
- * так, что оно перестаёт описывать хоть что-нибудь.
65
+ * Median is preferred over average: a single forgotten task can skew
66
+ * the average heavily.
67
67
  *
68
68
  * @param {Array<{task: object, moves: object[]}>} rows
69
69
  * @returns {Array<{column: string, mean: number, median: number, tasks: number}>}
@@ -88,10 +88,9 @@ export function columnStats(rows, now) {
88
88
  }
89
89
 
90
90
  /**
91
- * Задачи, залежавшиеся в своей колонке дольше порога.
91
+ * Tasks that stayed in their current column longer than threshold.
92
92
  *
93
- * Считаем от входа в колонку, а не от заведения: задача может быть старой и
94
- * при этом двигаться каждый день.
93
+ * Measured from column entry timestamp rather than creation date.
95
94
  *
96
95
  * @returns {Array<{id: string, title: string, column: string, since: number}>}
97
96
  */
@@ -112,10 +111,9 @@ export function stale(tasks, now, thresholdMs) {
112
111
  }
113
112
 
114
113
  /**
115
- * Сколько задач доехало до «Выполнено» за период.
114
+ * How many tasks reached 'done' within given time period.
116
115
  *
117
- * Считаем по переходам, а не по текущей колонке: задача, ушедшая в архив,
118
- * всё равно была сделана.
116
+ * Computed from transitions rather than current column to account for archived tasks.
119
117
  */
120
118
  export function doneWithin(rows, from, to) {
121
119
  let count = 0
package/lib/order.js CHANGED
@@ -1,35 +1,33 @@
1
- // Дробный индекс порядка карточек.
1
+ // Fractional ordering index for cards.
2
2
  //
3
- // Наивный подход хранит целочисленный индекс, и перетаскивание карточки в
4
- // начало колонки из десяти строк требует переписать все десять. Здесь порядок
5
- // хранится строкой, а новое значение выдаётся МЕЖДУ двумя соседями, поэтому
6
- // перетаскивание — всегда один UPDATE одной строки, сколько бы карточек ни
7
- // было в колонке.
3
+ // Naive approach stores integer index, and dragging card to the top of column
4
+ // with ten items requires rewriting all ten. Here order is stored as string,
5
+ // and new value is generated BETWEEN two neighbors, so drag-and-drop is always
6
+ // a single UPDATE of one row regardless of how many cards are in column.
8
7
  //
9
- // Реализация строковая намеренно. Вариант на числах с плавающей точкой
10
- // проходит поверхностную проверку и разваливается примерно на пятидесятой
11
- // вставке в одно и то же место, когда кончается разрядность.
8
+ // String-based implementation is intentional. Floating-point numbers fail
9
+ // after ~50 insertions into the same position when precision runs out.
12
10
  //
13
- // Ключ читается как дробь после подразумеваемого «0.»: сравнение обычное
14
- // лексикографическое. Ключ никогда не заканчивается первым символом алфавита —
15
- // иначе следующее деление слева стало бы невозможным.
11
+ // Key is read as fraction after implicit '0.': comparison is standard
12
+ // lexicographical. Key never ends with first alphabet character to ensure
13
+ // subsequent division remains possible.
16
14
 
17
- /** Алфавит в порядке возрастания кодов символов. */
15
+ /** Alphabet in ascending character code order. */
18
16
  const DIGITS = '0123456789abcdefghijklmnopqrstuvwxyz'
19
17
  const FIRST = DIGITS[0]
20
18
 
21
- /** Значение строго между `a` и `b`; `b === undefined` означает «до конца». */
19
+ /** Value strictly between `a` and `b`; `b === undefined` means unbounded right. */
22
20
  function midpoint(a, b) {
23
21
  if (b !== undefined && a >= b) {
24
- throw new Error(`порядок нарушен: ${JSON.stringify(a)} не меньше ${JSON.stringify(b)}`)
22
+ throw new Error(`order violated: ${JSON.stringify(a)} not less than ${JSON.stringify(b)}`)
25
23
  }
26
24
  if (a.endsWith(FIRST) || (b !== undefined && b.endsWith(FIRST))) {
27
- throw new Error('ключ порядка не может заканчиваться первым символом алфавита')
25
+ throw new Error('order key cannot end with first alphabet symbol')
28
26
  }
29
27
 
30
28
  if (b !== undefined) {
31
- // Общий префикс переносится в результат как есть, деление продолжается на
32
- // остатке — иначе для близких соседей пришлось бы наращивать длину с нуля.
29
+ // Common prefix carried as-is, division continues on remainder
30
+ // to avoid inflating key length unnecessarily.
33
31
  let n = 0
34
32
  while ((a[n] ?? FIRST) === b[n]) n += 1
35
33
  if (n > 0) return b.slice(0, n) + midpoint(a.slice(n), b.slice(n))
@@ -37,7 +35,7 @@ function midpoint(a, b) {
37
35
 
38
36
  const from = a === '' ? 0 : DIGITS.indexOf(a[0])
39
37
  const to = b === undefined ? DIGITS.length : DIGITS.indexOf(b[0])
40
- if (from < 0 || to < 0) throw new Error('ключ порядка содержит символ вне алфавита')
38
+ if (from < 0 || to < 0) throw new Error('order key contains character outside alphabet')
41
39
 
42
40
  if (to - from > 1) {
43
41
  return DIGITS[Math.round(0.5 * (from + to))]
@@ -45,21 +43,20 @@ function midpoint(a, b) {
45
43
  if (b !== undefined && b.length > 1) {
46
44
  return b.slice(0, 1)
47
45
  }
48
- // Соседние символы: занимаем разряд `a` и делим следующий.
46
+ // Adjacent symbols: take `a` digit and divide next position.
49
47
  return DIGITS[from] + midpoint(a.slice(1), undefined)
50
48
  }
51
49
 
52
50
  /**
53
- * Ключ порядка строго между двумя соседями.
51
+ * Fractional order key strictly between two neighbors.
54
52
  *
55
- * Оба соседа необязательны: без обоих выдаётся стартовое значение, без правого
56
- * — значение после `a`, без левого — значение перед `b`. Результат всегда
57
- * можно снова разделить.
53
+ * Both neighbors are optional: without either returns default initial value;
54
+ * without right returns value after `a`; without left returns value before `b`.
58
55
  *
59
- * @param {string} [a] ключ карточки слева
60
- * @param {string} [b] ключ карточки справа
61
- * @returns {string} ключ строго между ними
62
- * @throws если `a` не меньше `b` либо ключ содержит символ вне алфавита
56
+ * @param {string} [a] left neighbor order key
57
+ * @param {string} [b] right neighbor order key
58
+ * @returns {string} key strictly between them
59
+ * @throws if `a` is not less than `b` or key contains invalid character
63
60
  */
64
61
  export function keyBetween(a, b) {
65
62
  return midpoint(a ?? '', b ?? undefined)
package/lib/outbound.js CHANGED
@@ -1,22 +1,21 @@
1
- // Канбан → Gitea: закрытие issue.
1
+ // Kanban -> Gitea: closing issue.
2
2
  //
3
- // Отправка идёт очередью, а не прямо из обработчика переноса. Причина простая:
4
- // Gitea может быть недоступен, а доска обязана двигать карточки и при
5
- // недоступном Gitea. Перенос, ждущий сети, превратил бы доску в заложника
6
- // чужого времени ответа.
3
+ // Sending runs via queue rather than synchronously in move handler.
4
+ // Gitea may be unreachable, but the board must still move cards.
5
+ // A network-blocking move would make the board hostage to external latency.
7
6
  //
8
- // Неудачная отправка не теряется и не молчит: она повторяется, а после
9
- // нескольких неудач попадает в журнал задачи как проблема. Молча съеденное
10
- // расхождение — худшее, что может сделать синхронизация.
7
+ // Failed dispatch is not dropped silently: it retries, and after
8
+ // several attempts is logged as a problem in task history.
9
+ // Silently swallowed divergence is the worst sync failure mode.
11
10
  //
12
- // Меток колонок доска НЕ ставит. Она их никогда и не читала: колонка выводится
13
- // из состояния issue, ветки и pull request, а метка лишь пересказывала в Gitea
14
- // то, что там уже написано. Взамен доска не трогает чужую разметку вовсе.
11
+ // The board does NOT set column labels. It never reads them:
12
+ // column is derived from issue/branch/PR state, while labels only duplicate it.
13
+ // In exchange, the board leaves external labels untouched.
15
14
 
16
15
  /**
17
- * Что отправить в Gitea при смене ответственного.
16
+ * What to send to Gitea on assignee change.
18
17
  *
19
- * Своей задачи (без issue) это не касается: назначать там некого и негде.
18
+ * Does not apply to local tasks (without issue): no remote assignee exists.
20
19
  */
21
20
  export function planAssign(task, login) {
22
21
  if (typeof task?.issueNumber !== 'number') return []
@@ -31,20 +30,20 @@ export function planAssign(task, login) {
31
30
 
32
31
  export function planOutbound(task, column, config) {
33
32
  if (typeof task?.issueNumber !== 'number') return []
34
- // Закрытие issue — единственное, что доска сообщает Gitea: это факт, а не
35
- // пересказ фактов, которые в issue и так видны.
33
+ // Closing issue is the only mutation sent to Gitea: it is a concrete action,
34
+ // not redundant mirroring.
36
35
  return column === 'done'
37
36
  ? [{ kind: 'close', owner: task.owner, repo: task.repo, index: task.issueNumber }]
38
37
  : []
39
38
  }
40
39
 
41
40
  /**
42
- * Очередь отправки с повторами.
41
+ * Dispatch queue with retries.
43
42
  *
44
43
  * @param {object} options
45
- * @param {object} options.gitea клиент
46
- * @param {object} options.store хранилище — туда пишется отчёт о неудаче
47
- * @param {number} [options.maxAttempts] после скольких неудач сдаться и записать
44
+ * @param {object} options.gitea client
45
+ * @param {object} options.store store where failures are reported
46
+ * @param {number} [options.maxAttempts] max failed attempts before giving up
48
47
  */
49
48
  export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
50
49
  const queue = []
@@ -57,11 +56,11 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
57
56
  },
58
57
 
59
58
  /**
60
- * Попробовать отправить всё накопленное.
59
+ * Try delivering all queued operations.
61
60
  *
62
- * Успех убирает операцию, неудача возвращает её в очередь до предела
63
- * попыток. Исчерпав попытки, операция НЕ исчезает молча: о ней пишется в
64
- * журнал задачи, чтобы расхождение с Gitea было видно человеку.
61
+ * Success clears operation, failure reenqueues until attempt limit.
62
+ * Once attempts are exhausted, operation is NOT dropped silently:
63
+ * recorded in task history so divergence is visible to user.
65
64
  */
66
65
  async flush() {
67
66
  const pending = queue.splice(0)
@@ -89,7 +88,7 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
89
88
  continue
90
89
  }
91
90
  dropped += 1
92
- logger?.warn?.(`dsh-kanban: отправка в Gitea не удалась окончательно: ${error?.message}`)
91
+ logger?.warn?.(`dsh-kanban: Gitea delivery failed permanently: ${error?.message}`)
93
92
  try {
94
93
  const task = store.getTask(item.taskId)
95
94
  if (task !== undefined) {
@@ -98,10 +97,10 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
98
97
  fromCol: task.column,
99
98
  toCol: task.column,
100
99
  source: 'gitea',
101
- detail: `не отправлено в Gitea после ${item.attempts} попыток: ${item.op.kind}`,
100
+ detail: `failed delivery to Gitea after ${item.attempts} attempts: ${item.op.kind}`,
102
101
  })
103
102
  }
104
- } catch { /* журнал не должен ронять отправку */ }
103
+ } catch { /* log must not fail delivery */ }
105
104
  }
106
105
  }
107
106
  return { sent, retried, dropped }
package/lib/plan.js CHANGED
@@ -1,31 +1,28 @@
1
- // План работы агента и состояние задачи.
1
+ // Agent execution plan and task state.
2
2
  //
3
- // План получаем ПРЯМО — своим инструментом, а не разбором сообщений агента:
4
- // разбор это гадание, а инструмент `todo_list` принадлежит чужому плагину, и
5
- // опираться на его события значит завести зависимость.
3
+ // Plan is captured DIRECTLY via native tool rather than parsing chat messages:
4
+ // message parsing is heuristic, and external todo tools introduce fragile dependencies.
6
5
  //
7
- // Состояние тоже факт, а не догадка по таймауту. Порог тишины в настройки не
8
- // выносим: он был бы догадкой там, где есть точный признак.
6
+ // State is an explicit fact rather than timeout inference.
9
7
 
10
- /** Пункты плана дальше этого числа не храним: план — не журнал. */
8
+ /** Maximum plan items retained: plan is not a log history. */
11
9
  export const MAX_ITEMS = 50
12
10
 
13
- /** Длиннее этого пункт обрезаем: он едет на карточку в одну строку. */
11
+ /** Truncate items longer than this: displayed in card single-line view. */
14
12
  export const MAX_ITEM_LENGTH = 120
15
13
 
16
14
  const EMPTY = { items: [], current: 0 }
17
15
 
18
- /** Приведение пункта к строке разумной длины. */
16
+ /** Coerce item to string of reasonable length. */
19
17
  function cleanItem(value) {
20
18
  const text = String(value ?? '').replace(/\s+/g, ' ').trim()
21
19
  return text.length > MAX_ITEM_LENGTH ? text.slice(0, MAX_ITEM_LENGTH - 1) + '…' : text
22
20
  }
23
21
 
24
22
  /**
25
- * Разобрать план из хранилища.
23
+ * Parse plan from storage.
26
24
  *
27
- * Испорченное поле не должно ронять чтение всей доски, поэтому любой сбой
28
- * разбора превращается в пустой план.
25
+ * Corrupted field must not break board loading; errors return empty plan.
29
26
  */
30
27
  export function parsePlan(raw) {
31
28
  if (typeof raw !== 'string' || raw === '') return EMPTY
@@ -45,11 +42,8 @@ export function serializePlan(plan) {
45
42
  }
46
43
 
47
44
  /**
48
- * Указатель на пункт в работе.
45
+ * Pointer to active plan item.
49
46
  *
50
- * Ноль означает «плана нет либо он ещё не начат»; значение больше длины плана —
51
- * «всё сделано», и удерживать его на последнем пункте нельзя: тогда законченный
52
- * план вечно показывал бы последний пункт как текущий.
53
47
  */
54
48
  function clampCurrent(value, total) {
55
49
  const n = Number(value)
@@ -58,11 +52,8 @@ function clampCurrent(value, total) {
58
52
  }
59
53
 
60
54
  /**
61
- * Наложить правку инструмента на прежний план.
55
+ * Apply tool update to existing plan.
62
56
  *
63
- * Пункты необязательны: агент публикует план один раз, а дальше двигает только
64
- * указатель. Требовать список на каждом шаге значило бы гонять его целиком ради
65
- * одного числа.
66
57
  */
67
58
  export function applyPlan(previous, patch) {
68
59
  const prev = previous ?? EMPTY
@@ -76,10 +67,8 @@ export function applyPlan(previous, patch) {
76
67
  }
77
68
 
78
69
  /**
79
- * Что показать о плане.
70
+ * Plan display metadata.
80
71
  *
81
- * Числа и текст пункта отдаём отдельно: строку «3 из 7» собирает браузер, он
82
- * же её и переводит.
83
72
  *
84
73
  * @returns {{total: number, done: number, current: number, text: string}|undefined}
85
74
  */
@@ -87,14 +76,14 @@ export function planProgress(plan) {
87
76
  const items = plan?.items ?? []
88
77
  if (items.length === 0) return undefined
89
78
  const current = clampCurrent(plan?.current, items.length)
90
- // Всё, что до текущего пункта, считается сделанным. Модель линейная: агент
91
- // двигает одно число, а не ведёт список галочек.
79
+ // Items before active pointer are considered completed.
80
+ // Items before active pointer are considered completed.
92
81
  const done = current === 0 ? 0 : Math.min(current - 1, items.length)
93
82
  const text = current >= 1 && current <= items.length ? items[current - 1] : ''
94
83
  return { total: items.length, done, current, text }
95
84
  }
96
85
 
97
- /** Отметки пунктов для окна карточки. */
86
+ /** Plan item checklist state. */
98
87
  export function planItems(plan) {
99
88
  const items = plan?.items ?? []
100
89
  const current = clampCurrent(plan?.current, items.length)
@@ -106,43 +95,32 @@ export function planItems(plan) {
106
95
  }
107
96
 
108
97
  /**
109
- * Состояние задачи.
98
+ * Task state resolution.
110
99
  *
111
- * `stopped` — не догадка по тишине, а факт: у задачи записана сессия, но живого
112
- * агента за ней нет. Так выглядит прерванная работа после перезапуска харнесса.
113
100
  *
114
- * `queued` — задача отдана сессии, но агент до неё не дошёл: она ждёт своей
115
- * очереди. Выводится из отметки постановки, а не хранится отдельным полем.
116
101
  *
117
102
  * @param {{task: object, live: 'running'|'idle'|undefined}} args
118
- * `live` — состояние живого агента; `undefined` означает, что агента нет.
119
103
  */
120
104
  export function taskState({ task, live }) {
121
105
  const sessionId = typeof task?.sessionId === 'string' ? task.sessionId : ''
122
106
  if (sessionId === '') return 'none'
123
- // Задача отдана агенту, но он до неё ещё не дошёл. Без этого состояния
124
- // десять карточек в бэклоге ничем не отличаются от нетронутых, и через час
125
- // никто не вспомнит, какие уже отданы.
107
+ // Task queued for agent session.
108
+ // Task queued for agent session.
109
+ // Task queued for agent session.
126
110
  if (typeof task?.queuedAt === 'number' && task.queuedAt > 0) return 'queued'
127
- // Ожидание человека важнее хода: агент технически «работает», но без ответа
128
- // не сдвинется, и именно это надо показать.
111
+ // Human waiting takes precedence over running status.
112
+ // Human waiting takes precedence over running status.
129
113
  if (task?.waiting === true) return 'waiting'
130
114
  if (live === undefined) return 'stopped'
131
115
  return live === 'running' ? 'running' : 'idle'
132
116
  }
133
117
 
134
118
  /**
135
- * Номер задачи в очереди своей сессии.
119
+ * Task index in session queue.
136
120
  *
137
- * Считается на лету, а не хранится: агент берёт задачи одну за другой, и у
138
- * оставшихся третий сам становится вторым. Хранимый номер пришлось бы
139
- * пересчитывать, и однажды его бы забыли.
140
121
  *
141
- * Порядок — по отметке постановки, а НЕ по положению карточки в колонке.
142
- * Перетаскивание мышью — про то, как задачи лежат; очередь — про то, в каком
143
- * порядке их получит агент, и менять второе первым нельзя.
144
122
  *
145
- * @returns {number|undefined} место начиная с единицы
123
+ * @returns {number|undefined} 1-based index in queue
146
124
  */
147
125
  export function queuePosition(task, sameSession) {
148
126
  const at = task?.queuedAt
package/lib/power.js CHANGED
@@ -1,5 +1,5 @@
1
- // dsh-kanban: кроссплатформенный ингибитор сна системы во время работы задач.
2
- // Поддерживает Windows (PowerShell + Win32 API), Linux (systemd-inhibit) и macOS (caffeinate).
1
+ // dsh-kanban: cross-platform OS sleep inhibitor during active tasks.
2
+ // Supports Windows (PowerShell + Win32 API), Linux (systemd-inhibit) and macOS (caffeinate).
3
3
 
4
4
  import { spawn as defaultSpawn } from 'node:child_process'
5
5
  import { existsSync } from 'node:fs'