@goodandready/dsh-kanban 0.2.19 → 0.2.21

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/routes.js CHANGED
@@ -1,12 +1,9 @@
1
- // Обработчики HTTP доски.
1
+ // HTTP route handlers for the board.
2
2
  //
3
- // Браузерная половина не имеет доступа ни к хранилищу, ни к службам хоста —
4
- // она ходит сюда. Обработчики написаны так, чтобы проверяться без HTTP:
5
- // принимают разобранный вход и хранилище, возвращают объект ответа. Обвязка
6
- // вокруг req/res живёт в `lib/index.js`.
7
- //
8
- // Перехвата чужих маршрутов и промежуточных обработчиков в DSH нет:
9
- // `ctx.webServer.register` — единственный способ.
3
+ // The client bundle runs in the browser without direct access to SQLite or host services;
4
+ // it interacts with these handlers. Handlers accept parsed inputs and the store instance,
5
+ // returning response objects to facilitate testing without full HTTP server setups.
6
+ // Request/response wrapper integration lives in `lib/index.js`.
10
7
 
11
8
  import { COLUMN_ORDER, columnsOf, wipLimitField, columnNamesOf } from './config.js'
12
9
  import { parsePlan, planItems, planProgress, taskState, queuePosition } from './plan.js'
@@ -16,7 +13,7 @@ import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from './filters.js'
16
13
  import { parseBody } from './markdown.js'
17
14
  import { computeNextRun } from './cron.js'
18
15
 
19
- /** Больше этого тело запроса не читаем: без предела один запрос кладёт хост. */
16
+ /** Maximum request payload size to prevent denial of service. */
20
17
  export const MAX_BODY_BYTES = 256 * 1024
21
18
 
22
19
  function isLoopbackAddress(addr) {
@@ -28,17 +25,16 @@ function isLoopbackAddress(addr) {
28
25
  }
29
26
 
30
27
  /**
31
- * Запрос с чужого сайта. Изменяющие маршруты правят локальное состояние, и
32
- * пускать в них кросс-сайтовый запрос нельзя (#261).
33
- * Защита требует:
34
- * 1. Наличие безопасного sec-fetch-site ('same-origin', 'same-site', 'none').
35
- * 2. Совпадение origin и host (если origin передан).
36
- * 3. Loopback-источник (remoteAddress: 127.0.0.1, ::1, ::ffff:127.0.0.1).
28
+ * Cross-site request validation (#261).
29
+ * Mutating endpoints modify local state and reject cross-site requests:
30
+ * 1. Safe sec-fetch-site ('same-origin', 'same-site', 'none').
31
+ * 2. Origin matches host header (when origin is present).
32
+ * 3. Loopback remoteAddress (127.0.0.1, ::1, ::ffff:127.0.0.1).
37
33
  */
38
34
  export function isTrustedRequest(req) {
39
35
  if (!req || !req.headers) return false
40
36
 
41
- // 1. Проверка sec-fetch-site (отсутствие или cross-site запрещены)
37
+ // 1. Verify sec-fetch-site header (missing or cross-site rejected)
42
38
  const site = req.headers['sec-fetch-site']
43
39
  if (!site || site === 'cross-site') {
44
40
  return false
@@ -47,7 +43,7 @@ export function isTrustedRequest(req) {
47
43
  return false
48
44
  }
49
45
 
50
- // 2. Сверка origin с host
46
+ // 2. Validate origin against host
51
47
  const origin = req.headers.origin
52
48
  if (origin !== undefined) {
53
49
  if (origin === 'null') return false
@@ -60,7 +56,7 @@ export function isTrustedRequest(req) {
60
56
  }
61
57
  }
62
58
 
63
- // 3. Проверка loopback remoteAddress
59
+ // 3. Verify loopback remoteAddress
64
60
  const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
65
61
  if (remote && !isLoopbackAddress(remote)) {
66
62
  return false
@@ -69,7 +65,7 @@ export function isTrustedRequest(req) {
69
65
  return true
70
66
  }
71
67
 
72
- /** Разобрать `/dsh-kanban/task/<id>[/<action>]`; иначе undefined. */
68
+ /** Parse `/dsh-kanban/task/<id>[/<action>]`; returns undefined if non-matching. */
73
69
  export function parseTaskPath(pathname) {
74
70
  const m = /^\/dsh-kanban\/task\/([^/]+)(?:\/([a-z-]+))?\/?$/.exec(pathname ?? '')
75
71
  if (m === null) return undefined
@@ -77,19 +73,14 @@ export function parseTaskPath(pathname) {
77
73
  }
78
74
 
79
75
  /**
80
- * Задача с разобранным планом и состоянием.
81
- *
82
- * Сырую строку плана наружу не отдаём: браузерная половина — отдельный бандл,
83
- * она не может позвать `lib/plan.js`, и разбор пришлось бы писать второй раз.
76
+ * Enrich task with parsed plan, state, and filters.
84
77
  *
85
- * `liveOf` возвращает состояние живого агента сессии либо `undefined`, если
86
- * агента нет. Без него состояние не вычислить: «остановился» — это факт об
87
- * отсутствии агента, а не догадка по тишине.
78
+ * Plan is pre-parsed on server because client bundle cannot import `lib/plan.js`.
79
+ * `liveOf` returns active agent state or undefined if session is inactive.
88
80
  */
89
81
  function withPlan(task, liveOf, config, now, sameSession) {
90
82
  const plan = parsePlan(task.plan)
91
- // Отказ реестра — это «агента нет», а не повод не собрать доску: одна
92
- // непрочитанная сессия не должна прятать от человека все задачи.
83
+ // Registry error implies agent unavailable; do not fail whole board render.
93
84
  let live
94
85
  try {
95
86
  if (typeof liveOf === 'function' && task.sessionId) live = liveOf(task.sessionId)
@@ -98,57 +89,44 @@ function withPlan(task, liveOf, config, now, sameSession) {
98
89
  ...task,
99
90
  plan: { items: planItems(plan), progress: planProgress(plan) },
100
91
  state: taskState({ task, live }),
101
- // Номер в очереди считается на лету: агент берёт задачи одну за другой, и
102
- // у оставшихся третий сам становится вторым.
92
+ // Queue position calculated dynamically across active tasks in same session.
103
93
  queuePos: queuePosition(task, sameSession),
104
- // Метки разобраны здесь же: браузер сверяет списки, а не разбирает строки.
94
+ // Label facets extracted on server for consistent client filtering.
105
95
  facets: facetsOfTask(task),
106
- // Считаем здесь, а не в браузере: порог живёт в настройках, а настройки —
107
- // на хосте. Отдавать браузеру ещё и порог значило бы отдавать ему решение.
96
+ // Stale calculation performed on host where configuration resides.
108
97
  stale: isStale({
109
98
  now,
110
99
  updatedAt: task.updatedAt,
111
100
  sessionId: task.sessionId,
112
101
  afterMinutes: config?.staleAfterMin,
113
102
  }),
114
- // Просрочено — дедлайн есть и он раньше «сейчас». Считается на хосте,
115
- // чтобы браузер не зависел от своих часов.
103
+ // Overdue calculated on host using server clock.
116
104
  overdue: typeof task.dueAt === 'number' && task.dueAt > 0 && task.dueAt < now,
117
105
  }
118
106
  }
119
107
 
120
- /** Разложить задачи: по колонкам, внутри — по проектам, внутри — идущее наверх. */
108
+ /** Order tasks: by column, grouped by repository, with active tasks on top. */
121
109
  function orderForBoard(tasks, columns) {
122
110
  const out = []
123
111
  for (const column of columns) {
124
112
  const inColumn = tasks.filter((t) => t.column === column)
125
113
  for (const group of groupByRepo(sortByLife(inColumn))) out.push(...group.tasks)
126
114
  }
127
- // Задачи в колонке, которой на этой доске нет, не теряем: спрятать их совсем
128
- // хуже, чем показать в непривычном месте.
115
+ // Retain tasks in columns not visible on current board to prevent data loss.
129
116
  const seen = new Set(out.map((t) => t.id))
130
117
  for (const task of tasks) if (!seen.has(task.id)) out.push(task)
131
118
  return out
132
119
  }
133
120
 
134
121
  /**
135
- * Ответ доски: колонки в фиксированном порядке, задачи и пределы.
136
- * Порядок колонок задан кодом, а не настройками: он повторяет воркфлоу, и
137
- * переставлять его местами незачем.
122
+ * Build board response payload: columns in canonical order, tasks, and limits.
138
123
  */
139
124
  export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot }) {
140
- // Одно «сейчас» на всю сборку: иначе соседние карточки посчитались бы по
141
- // разным моментам, и разница вылезла бы ровно на границе минуты.
125
+ // Uniform timestamp across snapshot calculation.
142
126
  const now = Date.now()
143
- // Порядок задаём здесь, а не в браузере: правила группировки и подъёма
144
- // повторять во второй раз значило бы завести вторую их редакцию.
145
- //
146
- // Отдаём задачи УЖЕ разложенными — сперва по проектам, внутри проекта идущая
147
- // работа наверх. Браузер рисует заголовок при смене проекта, и после отбора
148
- // группы остаются целыми, потому что отбор порядка не меняет.
127
+ // Tasks are sorted and grouped on server: repo grouping with active items first.
149
128
  const raw = store.listTasks({ board, repo })
150
- // Очередь считается внутри своей сессии, а не по доске целиком: две сессии —
151
- // две независимые очереди.
129
+ // Queue position scoped per session.
152
130
  const bySession = new Map()
153
131
  for (const task of raw) {
154
132
  const id = typeof task.sessionId === 'string' ? task.sessionId : ''
@@ -167,38 +145,38 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
167
145
  columnsOf(store.boardKind(board)),
168
146
  )
169
147
  const kind = store.boardKind(board)
170
- // Своё название приходит с сервера, а перевод остаётся на браузере: конфиг
171
- // живёт здесь, а языки — там, и смешивать их не надо.
148
+ // Column custom names sent from server; locale translation handled by client.
172
149
  const names = columnNamesOf(config)
173
150
  const columns = columnsOf(kind).map((id) => {
174
151
  const count = tasks.filter((t) => t.column === id).length
175
152
  const limit = wipLimitField(config, id)
176
153
  return { id, name: names[id], count, limit, overLimit: limit !== undefined && count > limit }
177
154
  })
178
- // Отборы считаем по ВСЕМ задачам доски, а не по отобранным: счётчик рядом со
179
- // значением отвечает «сколько там всего», а не «сколько осталось после моего
180
- // же выбора».
155
+ // Filter counts calculated over all tasks on board.
181
156
  return {
182
157
  board, kind, now, boards: store.listBoards(), columns, tasks,
183
- // Фактический корень проектов: {path, set} — путь и признак «задан
184
- // настройкой». Считается на хосте — браузер не знает ни настройку, ни cwd.
158
+ // Resolved project root {path, set} computed on host.
185
159
  projectRoot: projectRoot ?? { path: '', set: false },
186
160
 
187
161
  facets: facetsOf(tasks),
188
- // Состояние сверки едет вместе с доской: отдельный опрос ради него был бы
189
- // вторым источником обращений и вторым местом, где это может отстать.
162
+ // Sync telemetry state attached directly to board snapshot.
190
163
  sync: sync ?? { state: 'never' },
191
164
  }
192
165
  }
193
166
 
194
167
  /**
195
- * Перенос карточки. Предел колонки — предупреждение, а не запрет: перенос
196
- * выполняется, а признак превышения уезжает в ответ, и доска подсветит
197
- * колонку. Запрет здесь превратил бы предел из ориентира в преграду.
168
+ * Card move transition. WIP limit is advisory: move executes, and exceeded status
169
+ * is returned for UI highlighting.
198
170
  */
199
171
  export function applyMove({ store, config, id, column, beforeId, afterId, source = 'manual', detail = '' }) {
200
172
  const before = store.getTask(id)
201
173
  if (before === undefined) return { error: 'task-not-found', status: 404 }
174
+ if (column !== undefined && column !== null) {
175
+ const allowed = columnsOf(store.boardKind(before.board))
176
+ if (!allowed.includes(column)) {
177
+ return { error: 'invalid-column', status: 400 }
178
+ }
179
+ }
202
180
  const task = store.moveTask(id, { column, beforeId, afterId })
203
181
  if (task.column !== before.column) {
204
182
  store.addTransition({
@@ -210,14 +188,12 @@ export function applyMove({ store, config, id, column, beforeId, afterId, source
210
188
  return { task, overLimit: limit !== undefined && count > limit }
211
189
  }
212
190
 
213
- /** Создание своей задачи (не из Gitea). */
191
+ /** Create local board task (not linked to Gitea). */
214
192
  export function createTask({ store, input }) {
215
193
  const title = typeof input?.title === 'string' ? input.title.trim() : ''
216
194
  if (title === '') return { error: 'title-required', status: 400 }
217
195
  const board = input?.board ?? 'main'
218
- // Колонка проверяется по ВИДУ доски, а не по общему списку: положить задачу
219
- // в `deploy` на простой доске значило бы спрятать её в колонке, которой там
220
- // нет.
196
+ // Validate column against board kind; simple boards do not have deploy/cleanup.
221
197
  const allowed = columnsOf(store.boardKind(board))
222
198
  const column = allowed.includes(input?.column) ? input.column : 'backlog'
223
199
  const task = store.createTask({
@@ -230,7 +206,7 @@ export function createTask({ store, input }) {
230
206
  return { task }
231
207
  }
232
208
 
233
- /** Правка полей карточки. Колонку и порядок правит только перенос. */
209
+ /** Update card fields. Column transitions are handled via applyMove. */
234
210
  export function updateTask({ store, id, input }) {
235
211
  const current = store.getTask(id)
236
212
  if (current === undefined) return { error: 'task-not-found', status: 404 }
@@ -238,12 +214,9 @@ export function updateTask({ store, id, input }) {
238
214
  if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
239
215
  if (typeof input?.body === 'string') patch.body = input.body
240
216
  if (Array.isArray(input?.labels)) patch.labels = input.labels.filter((l) => typeof l === 'string')
241
- // Приоритет — локальное свойство доски: в Gitea назад не пишется. Чужие
242
- // значения отбрасываются: одно испорченное поле не должно ронять правку
243
- // остальных.
217
+ // Priority is local to board and not synced back to Gitea.
244
218
  if (PRIORITIES.includes(input?.priority)) patch.priority = input.priority
245
- // Дедлайн — тоже локальное свойство. Ноль снимает, положительное число —
246
- // ставит; отрицательное и мусор отбрасываются.
219
+ // Deadline is local. 0 clears deadline; positive value sets it.
247
220
  if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
248
221
  patch.dueAt = Math.floor(input.dueAt)
249
222
  }
@@ -273,17 +246,12 @@ export function confirmPermission({ store, id }) {
273
246
  return { task: store.updateTask(id, { permissionConfirmedAt: Date.now() }) }
274
247
  }
275
248
 
276
- /** Известные приоритеты. Пустой — «без приоритета». */
249
+ /** Known priority values. Empty string indicates unprioritized. */
277
250
  const PRIORITIES = ['', 'high', 'medium', 'low']
278
251
 
279
252
  /**
280
- * Дописать заметку в тело задачи.
281
- *
282
- * Именно в тело, а не в отдельную ленту заметок и не комментарием в Gitea:
283
- * владелец выбрал самый простой из трёх смыслов, и он же самый дешёвый.
284
- *
285
- * Дописываем на сервере, а не в браузере: чип держит копию задачи, и склейка
286
- * у него потеряла бы всё, что тело нажило с момента её получения.
253
+ * Append note to task body.
254
+ * Handled on server to prevent overwriting edits made elsewhere.
287
255
  */
288
256
  export function appendNote({ store, id, input }) {
289
257
  const task = store.getTask(id)
@@ -295,10 +263,8 @@ export function appendNote({ store, id, input }) {
295
263
  }
296
264
 
297
265
  /**
298
- * Убрать карточку в архив или вернуть на доску.
299
- *
300
- * Возврат ставит её ровно туда, где она была: колонка и порядок сохранены, а
301
- * `archivedAt` — единственное, что менялось.
266
+ * Archive or unarchive card.
267
+ * Unarchiving restores card to its original column and position.
302
268
  */
303
269
  export function setArchived({ store, id, archived, now = Date.now() }) {
304
270
  const task = store.getTask(id)
@@ -306,16 +272,14 @@ export function setArchived({ store, id, archived, now = Date.now() }) {
306
272
  return { task: store.setArchived(id, archived ? now : 0) }
307
273
  }
308
274
 
309
- /** Архив: свой список, а не колонка — архив стадией работы не является. */
275
+ /** Query archived tasks list. */
310
276
  export function listArchive({ store }) {
311
277
  return { tasks: store.listArchived() }
312
278
  }
313
279
 
314
280
  /**
315
- * Назначить ответственного или снять назначение.
316
- *
317
- * Пустой логин — это снятие, а не отказ: «никто» такое же законное состояние
318
- * задачи, как и любой исполнитель.
281
+ * Assign task or clear assignment.
282
+ * Empty login unassigns the task.
319
283
  *
320
284
  * @returns {{task: object}|{error: string, status: number}}
321
285
  */
@@ -330,7 +294,7 @@ export function assignTask({ store, id, login }) {
330
294
  fromCol: task.column,
331
295
  toCol: task.column,
332
296
  source: 'manual',
333
- detail: who === '' ? 'ответственный снят' : `ответственный: ${who}`,
297
+ detail: who === '' ? 'unassigned' : `assigned: ${who}`,
334
298
  })
335
299
  return { task: store.getTask(id) }
336
300
  }
@@ -342,18 +306,11 @@ export function deleteTask({ store, id }) {
342
306
  }
343
307
 
344
308
  /**
345
- * Тело задачи, разобранное в дерево блоков.
346
- *
347
- * Разбираем здесь, а не в браузере: браузерная половина — отдельный бандл и
348
- * позвать `lib/markdown.js` не может. Заодно тело не едет в разметку строкой,
349
- * и подставить туда чужое нечем.
309
+ * Parse task markdown body into structured block elements.
310
+ * Done on server so client bundle does not need markdown parser.
350
311
  */
351
312
  /**
352
- * Метрики доски: где стоит работа и что залежалось.
353
- *
354
- * Считается по журналу переходов и ничего не записывает. Порог «залежалось»
355
- * приходит запросом, а не настройкой: это вопрос, который задают по-разному в
356
- * разные дни, а не свойство доски.
313
+ * Board metrics: WIP distribution and stale tasks.
357
314
  *
358
315
  * @returns {{columns: object[], done: object, stale: object[], tasks: number}}
359
316
  */
@@ -380,10 +337,8 @@ export function boardMetrics({ store, board = 'main', now = Date.now(), staleDay
380
337
  }
381
338
 
382
339
  /**
383
- * Можно ли предлагать «продолжить работу».
384
- *
385
- * Только когда сессия у задачи записана, а живого агента за ней нет: у живой
386
- * продолжать нечего, у задачи без сессии — нечего возобновлять.
340
+ * Check whether a task can be resumed.
341
+ * Eligible when task has recorded sessionId but no active agent is running.
387
342
  */
388
343
  export function canRevive(task, live) {
389
344
  const has = typeof task?.sessionId === 'string' && task.sessionId !== ''
@@ -401,14 +356,13 @@ export function taskLog({ store, id }) {
401
356
  return { transitions: store.listTransitions(id) }
402
357
  }
403
358
 
404
- /** Задача, привязанная к сессии; отсутствие задачи — штатный ответ. */
359
+ /** Find task associated with session; absence is normal response. */
405
360
  export function taskBySession({ store, sessionId, agents }) {
406
361
  const task = store.findTaskBySession(sessionId)
407
362
  if (task === undefined) return { task: null }
408
363
  let live
409
364
  try { live = agents?.get?.(sessionId) } catch { live = undefined }
410
- // Вид доски едет вместе с задачей: чип предлагает колонки, а предлагать
411
- // `deploy` задаче с простой доски значит звать в колонку, которой нет.
365
+ // Board kind returned with task so UI displays matching column choices.
412
366
  return {
413
367
  task,
414
368
  columns: columnsOf(store.boardKind(task.board)),
@@ -417,21 +371,14 @@ export function taskBySession({ store, sessionId, agents }) {
417
371
  }
418
372
 
419
373
  /**
420
- * Полный снимок доски для бэкапа/переноса.
421
- *
422
- * Отдаём всё, что есть в хранилище: доски, задачи (включая архивные) и журнал
423
- * переходов. Импорт восстанавливает это как есть.
374
+ * Export full board snapshot for backup or migration.
424
375
  */
425
376
  export function exportBoard({ store }) {
426
377
  return store.exportAll()
427
378
  }
428
379
 
429
380
  /**
430
- * Восстановить доску из снимка.
431
- *
432
- * Идемпотентно: существующая задача с тем же id не перезаписывается, а
433
- * пропускается — повторный импорт не плодит дубли. Доски создаются, если их
434
- * ещё нет.
381
+ * Restore board from backup snapshot (idempotent; skips existing IDs).
435
382
  *
436
383
  * @returns {{imported: number, skipped: number, error?: string}}
437
384
  */
@@ -491,28 +438,28 @@ export function importBoard({ store, input }) {
491
438
  }
492
439
 
493
440
  /**
494
- * Экспорт задач в Excel-совместимый CSV с UTF-8 BOM (#223).
441
+ * Export tasks to Excel-compatible CSV with UTF-8 BOM (#223).
495
442
  *
496
443
  * @param {{store: object, board?: string}} options
497
- * @returns {string} CSV-строка с префиксом \uFEFF
444
+ * @returns {string} CSV string with UTF-8 BOM prefix
498
445
  */
499
446
  export function exportTasksCsv({ store, board = 'main' }) {
500
447
  const tasks = store.listTasks({ board })
501
448
  const headers = [
502
449
  'ID',
503
- 'Заголовок',
504
- 'Колонка',
505
- 'Приоритет',
506
- 'Репозиторий',
450
+ 'Title',
451
+ 'Column',
452
+ 'Priority',
453
+ 'Repository',
507
454
  'Issue #',
508
- 'Исполнитель',
509
- 'Модель',
510
- 'Провайдер',
511
- 'Глубина рассуждения',
512
- 'Воркспейс',
513
- 'Ветка',
514
- 'Создана',
515
- 'Обновлена',
455
+ 'Assignee',
456
+ 'Model',
457
+ 'Provider',
458
+ 'Reasoning Effort',
459
+ 'Workspace',
460
+ 'Branch',
461
+ 'Created',
462
+ 'Updated',
516
463
  ]
517
464
 
518
465
  function escapeCsv(val) {
@@ -554,13 +501,13 @@ export function exportTasksCsv({ store, board = 'main' }) {
554
501
  rows.push(row.join(','))
555
502
  }
556
503
 
557
- // Префикс UTF-8 BOM для Microsoft Excel
504
+ // UTF-8 BOM prefix for Microsoft Excel
558
505
  return '\uFEFF' + rows.join('\r\n')
559
506
  }
560
507
 
561
508
 
562
509
  /**
563
- * Очистить воркдерево задачи.
510
+ * Clean up task worktree.
564
511
  */
565
512
  export async function handleCleanupWorktree({ store, task, workspaceRegistry, force, cleanupTaskWorktree }) {
566
513
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -574,7 +521,7 @@ export async function handleCleanupWorktree({ store, task, workspaceRegistry, fo
574
521
  }
575
522
 
576
523
  /**
577
- * Получить diff рабочей копии задачи.
524
+ * Get working copy diff for task.
578
525
  */
579
526
  export async function handleTaskDiff({ task, getTaskDiff }) {
580
527
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -583,7 +530,7 @@ export async function handleTaskDiff({ task, getTaskDiff }) {
583
530
  }
584
531
 
585
532
  /**
586
- * Получить список коммитов задачи.
533
+ * Get commit list for task.
587
534
  */
588
535
  export async function handleTaskCommits({ task, getTaskCommits }) {
589
536
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -592,7 +539,7 @@ export async function handleTaskCommits({ task, getTaskCommits }) {
592
539
  }
593
540
 
594
541
  /**
595
- * Слить воркдерево задачи и перевести в done.
542
+ * Merge task worktree branch and transition to done.
596
543
  */
597
544
  export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
598
545
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -613,13 +560,13 @@ export async function handleTaskMerge({ store, task, targetBranch, workspaceRegi
613
560
  fromCol: task.column,
614
561
  toCol: 'done',
615
562
  source: 'manual',
616
- detail: `принята и смержена ветка ${task.branch} в ${mergeResult.targetBranch}`,
563
+ detail: `accepted and merged branch ${task.branch} into ${mergeResult.targetBranch}`,
617
564
  })
618
565
  return { status: 200, body: { task: updated, merged: true, targetBranch: mergeResult.targetBranch } }
619
566
  }
620
567
 
621
568
  /**
622
- * Проверить статус воркдерева задачи (ветка, dirty, воркспейс).
569
+ * Check task worktree status (branch, dirty files, workspace).
623
570
  */
624
571
  export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
625
572
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -637,7 +584,7 @@ export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
637
584
  }
638
585
 
639
586
  /**
640
- * Обновить DoD чеклист задачи.
587
+ * Update Definition of Done checklist for task.
641
588
  */
642
589
  export function handleTaskChecklist({ store, task, input }) {
643
590
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -647,7 +594,7 @@ export function handleTaskChecklist({ store, task, input }) {
647
594
  }
648
595
 
649
596
  /**
650
- * Установить отчёт выполнения задачи.
597
+ * Set completion report for task.
651
598
  */
652
599
  export function handleTaskReport({ store, task, input }) {
653
600
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -656,7 +603,7 @@ export function handleTaskReport({ store, task, input }) {
656
603
  }
657
604
 
658
605
  /**
659
- * Получить комментарии задачи.
606
+ * Get comments for task.
660
607
  */
661
608
  export function handleGetTaskComments({ task }) {
662
609
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -664,7 +611,7 @@ export function handleGetTaskComments({ task }) {
664
611
  }
665
612
 
666
613
  /**
667
- * Добавить комментарий к задаче.
614
+ * Add comment to task.
668
615
  */
669
616
  export function handleAddTaskComment({ store, task, input }) {
670
617
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -676,7 +623,7 @@ export function handleAddTaskComment({ store, task, input }) {
676
623
  }
677
624
 
678
625
  /**
679
- * Принять задачу на ревью (перевод в done).
626
+ * Accept task review (transition to done).
680
627
  */
681
628
  export function handleAcceptTask({ store, task }) {
682
629
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -685,7 +632,7 @@ export function handleAcceptTask({ store, task }) {
685
632
  }
686
633
 
687
634
  /**
688
- * Отклонить задачу на ревью (перевод обратно в in_progress).
635
+ * Reject task review (return to in_progress).
689
636
  */
690
637
  export function handleRejectTask({ store, task, input }) {
691
638
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -696,7 +643,7 @@ export function handleRejectTask({ store, task, input }) {
696
643
  }
697
644
 
698
645
  /**
699
- * Сохранить дамп прогресса задачи.
646
+ * Save task progress dump snapshot.
700
647
  */
701
648
  export function handleProgressDump({ store, task, input, formatProgressDump }) {
702
649
  if (!task) return { status: 404, body: { error: 'task-not-found' } }