@goodandready/dsh-kanban 0.2.18 → 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/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,34 +145,28 @@ 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)
@@ -210,14 +182,12 @@ export function applyMove({ store, config, id, column, beforeId, afterId, source
210
182
  return { task, overLimit: limit !== undefined && count > limit }
211
183
  }
212
184
 
213
- /** Создание своей задачи (не из Gitea). */
185
+ /** Create local board task (not linked to Gitea). */
214
186
  export function createTask({ store, input }) {
215
187
  const title = typeof input?.title === 'string' ? input.title.trim() : ''
216
188
  if (title === '') return { error: 'title-required', status: 400 }
217
189
  const board = input?.board ?? 'main'
218
- // Колонка проверяется по ВИДУ доски, а не по общему списку: положить задачу
219
- // в `deploy` на простой доске значило бы спрятать её в колонке, которой там
220
- // нет.
190
+ // Validate column against board kind; simple boards do not have deploy/cleanup.
221
191
  const allowed = columnsOf(store.boardKind(board))
222
192
  const column = allowed.includes(input?.column) ? input.column : 'backlog'
223
193
  const task = store.createTask({
@@ -230,7 +200,7 @@ export function createTask({ store, input }) {
230
200
  return { task }
231
201
  }
232
202
 
233
- /** Правка полей карточки. Колонку и порядок правит только перенос. */
203
+ /** Update card fields. Column transitions are handled via applyMove. */
234
204
  export function updateTask({ store, id, input }) {
235
205
  const current = store.getTask(id)
236
206
  if (current === undefined) return { error: 'task-not-found', status: 404 }
@@ -238,12 +208,9 @@ export function updateTask({ store, id, input }) {
238
208
  if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
239
209
  if (typeof input?.body === 'string') patch.body = input.body
240
210
  if (Array.isArray(input?.labels)) patch.labels = input.labels.filter((l) => typeof l === 'string')
241
- // Приоритет — локальное свойство доски: в Gitea назад не пишется. Чужие
242
- // значения отбрасываются: одно испорченное поле не должно ронять правку
243
- // остальных.
211
+ // Priority is local to board and not synced back to Gitea.
244
212
  if (PRIORITIES.includes(input?.priority)) patch.priority = input.priority
245
- // Дедлайн — тоже локальное свойство. Ноль снимает, положительное число —
246
- // ставит; отрицательное и мусор отбрасываются.
213
+ // Deadline is local. 0 clears deadline; positive value sets it.
247
214
  if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
248
215
  patch.dueAt = Math.floor(input.dueAt)
249
216
  }
@@ -273,17 +240,12 @@ export function confirmPermission({ store, id }) {
273
240
  return { task: store.updateTask(id, { permissionConfirmedAt: Date.now() }) }
274
241
  }
275
242
 
276
- /** Известные приоритеты. Пустой — «без приоритета». */
243
+ /** Known priority values. Empty string indicates unprioritized. */
277
244
  const PRIORITIES = ['', 'high', 'medium', 'low']
278
245
 
279
246
  /**
280
- * Дописать заметку в тело задачи.
281
- *
282
- * Именно в тело, а не в отдельную ленту заметок и не комментарием в Gitea:
283
- * владелец выбрал самый простой из трёх смыслов, и он же самый дешёвый.
284
- *
285
- * Дописываем на сервере, а не в браузере: чип держит копию задачи, и склейка
286
- * у него потеряла бы всё, что тело нажило с момента её получения.
247
+ * Append note to task body.
248
+ * Handled on server to prevent overwriting edits made elsewhere.
287
249
  */
288
250
  export function appendNote({ store, id, input }) {
289
251
  const task = store.getTask(id)
@@ -295,10 +257,8 @@ export function appendNote({ store, id, input }) {
295
257
  }
296
258
 
297
259
  /**
298
- * Убрать карточку в архив или вернуть на доску.
299
- *
300
- * Возврат ставит её ровно туда, где она была: колонка и порядок сохранены, а
301
- * `archivedAt` — единственное, что менялось.
260
+ * Archive or unarchive card.
261
+ * Unarchiving restores card to its original column and position.
302
262
  */
303
263
  export function setArchived({ store, id, archived, now = Date.now() }) {
304
264
  const task = store.getTask(id)
@@ -306,16 +266,14 @@ export function setArchived({ store, id, archived, now = Date.now() }) {
306
266
  return { task: store.setArchived(id, archived ? now : 0) }
307
267
  }
308
268
 
309
- /** Архив: свой список, а не колонка — архив стадией работы не является. */
269
+ /** Query archived tasks list. */
310
270
  export function listArchive({ store }) {
311
271
  return { tasks: store.listArchived() }
312
272
  }
313
273
 
314
274
  /**
315
- * Назначить ответственного или снять назначение.
316
- *
317
- * Пустой логин — это снятие, а не отказ: «никто» такое же законное состояние
318
- * задачи, как и любой исполнитель.
275
+ * Assign task or clear assignment.
276
+ * Empty login unassigns the task.
319
277
  *
320
278
  * @returns {{task: object}|{error: string, status: number}}
321
279
  */
@@ -330,7 +288,7 @@ export function assignTask({ store, id, login }) {
330
288
  fromCol: task.column,
331
289
  toCol: task.column,
332
290
  source: 'manual',
333
- detail: who === '' ? 'ответственный снят' : `ответственный: ${who}`,
291
+ detail: who === '' ? 'unassigned' : `assigned: ${who}`,
334
292
  })
335
293
  return { task: store.getTask(id) }
336
294
  }
@@ -342,18 +300,11 @@ export function deleteTask({ store, id }) {
342
300
  }
343
301
 
344
302
  /**
345
- * Тело задачи, разобранное в дерево блоков.
346
- *
347
- * Разбираем здесь, а не в браузере: браузерная половина — отдельный бандл и
348
- * позвать `lib/markdown.js` не может. Заодно тело не едет в разметку строкой,
349
- * и подставить туда чужое нечем.
303
+ * Parse task markdown body into structured block elements.
304
+ * Done on server so client bundle does not need markdown parser.
350
305
  */
351
306
  /**
352
- * Метрики доски: где стоит работа и что залежалось.
353
- *
354
- * Считается по журналу переходов и ничего не записывает. Порог «залежалось»
355
- * приходит запросом, а не настройкой: это вопрос, который задают по-разному в
356
- * разные дни, а не свойство доски.
307
+ * Board metrics: WIP distribution and stale tasks.
357
308
  *
358
309
  * @returns {{columns: object[], done: object, stale: object[], tasks: number}}
359
310
  */
@@ -380,10 +331,8 @@ export function boardMetrics({ store, board = 'main', now = Date.now(), staleDay
380
331
  }
381
332
 
382
333
  /**
383
- * Можно ли предлагать «продолжить работу».
384
- *
385
- * Только когда сессия у задачи записана, а живого агента за ней нет: у живой
386
- * продолжать нечего, у задачи без сессии — нечего возобновлять.
334
+ * Check whether a task can be resumed.
335
+ * Eligible when task has recorded sessionId but no active agent is running.
387
336
  */
388
337
  export function canRevive(task, live) {
389
338
  const has = typeof task?.sessionId === 'string' && task.sessionId !== ''
@@ -401,14 +350,13 @@ export function taskLog({ store, id }) {
401
350
  return { transitions: store.listTransitions(id) }
402
351
  }
403
352
 
404
- /** Задача, привязанная к сессии; отсутствие задачи — штатный ответ. */
353
+ /** Find task associated with session; absence is normal response. */
405
354
  export function taskBySession({ store, sessionId, agents }) {
406
355
  const task = store.findTaskBySession(sessionId)
407
356
  if (task === undefined) return { task: null }
408
357
  let live
409
358
  try { live = agents?.get?.(sessionId) } catch { live = undefined }
410
- // Вид доски едет вместе с задачей: чип предлагает колонки, а предлагать
411
- // `deploy` задаче с простой доски значит звать в колонку, которой нет.
359
+ // Board kind returned with task so UI displays matching column choices.
412
360
  return {
413
361
  task,
414
362
  columns: columnsOf(store.boardKind(task.board)),
@@ -417,21 +365,14 @@ export function taskBySession({ store, sessionId, agents }) {
417
365
  }
418
366
 
419
367
  /**
420
- * Полный снимок доски для бэкапа/переноса.
421
- *
422
- * Отдаём всё, что есть в хранилище: доски, задачи (включая архивные) и журнал
423
- * переходов. Импорт восстанавливает это как есть.
368
+ * Export full board snapshot for backup or migration.
424
369
  */
425
370
  export function exportBoard({ store }) {
426
371
  return store.exportAll()
427
372
  }
428
373
 
429
374
  /**
430
- * Восстановить доску из снимка.
431
- *
432
- * Идемпотентно: существующая задача с тем же id не перезаписывается, а
433
- * пропускается — повторный импорт не плодит дубли. Доски создаются, если их
434
- * ещё нет.
375
+ * Restore board from backup snapshot (idempotent; skips existing IDs).
435
376
  *
436
377
  * @returns {{imported: number, skipped: number, error?: string}}
437
378
  */
@@ -491,28 +432,28 @@ export function importBoard({ store, input }) {
491
432
  }
492
433
 
493
434
  /**
494
- * Экспорт задач в Excel-совместимый CSV с UTF-8 BOM (#223).
435
+ * Export tasks to Excel-compatible CSV with UTF-8 BOM (#223).
495
436
  *
496
437
  * @param {{store: object, board?: string}} options
497
- * @returns {string} CSV-строка с префиксом \uFEFF
438
+ * @returns {string} CSV string with UTF-8 BOM prefix
498
439
  */
499
440
  export function exportTasksCsv({ store, board = 'main' }) {
500
441
  const tasks = store.listTasks({ board })
501
442
  const headers = [
502
443
  'ID',
503
- 'Заголовок',
504
- 'Колонка',
505
- 'Приоритет',
506
- 'Репозиторий',
444
+ 'Title',
445
+ 'Column',
446
+ 'Priority',
447
+ 'Repository',
507
448
  'Issue #',
508
- 'Исполнитель',
509
- 'Модель',
510
- 'Провайдер',
511
- 'Глубина рассуждения',
512
- 'Воркспейс',
513
- 'Ветка',
514
- 'Создана',
515
- 'Обновлена',
449
+ 'Assignee',
450
+ 'Model',
451
+ 'Provider',
452
+ 'Reasoning Effort',
453
+ 'Workspace',
454
+ 'Branch',
455
+ 'Created',
456
+ 'Updated',
516
457
  ]
517
458
 
518
459
  function escapeCsv(val) {
@@ -554,13 +495,13 @@ export function exportTasksCsv({ store, board = 'main' }) {
554
495
  rows.push(row.join(','))
555
496
  }
556
497
 
557
- // Префикс UTF-8 BOM для Microsoft Excel
498
+ // UTF-8 BOM prefix for Microsoft Excel
558
499
  return '\uFEFF' + rows.join('\r\n')
559
500
  }
560
501
 
561
502
 
562
503
  /**
563
- * Очистить воркдерево задачи.
504
+ * Clean up task worktree.
564
505
  */
565
506
  export async function handleCleanupWorktree({ store, task, workspaceRegistry, force, cleanupTaskWorktree }) {
566
507
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -574,7 +515,7 @@ export async function handleCleanupWorktree({ store, task, workspaceRegistry, fo
574
515
  }
575
516
 
576
517
  /**
577
- * Получить diff рабочей копии задачи.
518
+ * Get working copy diff for task.
578
519
  */
579
520
  export async function handleTaskDiff({ task, getTaskDiff }) {
580
521
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -583,7 +524,7 @@ export async function handleTaskDiff({ task, getTaskDiff }) {
583
524
  }
584
525
 
585
526
  /**
586
- * Получить список коммитов задачи.
527
+ * Get commit list for task.
587
528
  */
588
529
  export async function handleTaskCommits({ task, getTaskCommits }) {
589
530
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -592,7 +533,7 @@ export async function handleTaskCommits({ task, getTaskCommits }) {
592
533
  }
593
534
 
594
535
  /**
595
- * Слить воркдерево задачи и перевести в done.
536
+ * Merge task worktree branch and transition to done.
596
537
  */
597
538
  export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
598
539
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -613,13 +554,13 @@ export async function handleTaskMerge({ store, task, targetBranch, workspaceRegi
613
554
  fromCol: task.column,
614
555
  toCol: 'done',
615
556
  source: 'manual',
616
- detail: `принята и смержена ветка ${task.branch} в ${mergeResult.targetBranch}`,
557
+ detail: `accepted and merged branch ${task.branch} into ${mergeResult.targetBranch}`,
617
558
  })
618
559
  return { status: 200, body: { task: updated, merged: true, targetBranch: mergeResult.targetBranch } }
619
560
  }
620
561
 
621
562
  /**
622
- * Проверить статус воркдерева задачи (ветка, dirty, воркспейс).
563
+ * Check task worktree status (branch, dirty files, workspace).
623
564
  */
624
565
  export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
625
566
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -637,7 +578,7 @@ export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
637
578
  }
638
579
 
639
580
  /**
640
- * Обновить DoD чеклист задачи.
581
+ * Update Definition of Done checklist for task.
641
582
  */
642
583
  export function handleTaskChecklist({ store, task, input }) {
643
584
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -647,7 +588,7 @@ export function handleTaskChecklist({ store, task, input }) {
647
588
  }
648
589
 
649
590
  /**
650
- * Установить отчёт выполнения задачи.
591
+ * Set completion report for task.
651
592
  */
652
593
  export function handleTaskReport({ store, task, input }) {
653
594
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -656,7 +597,7 @@ export function handleTaskReport({ store, task, input }) {
656
597
  }
657
598
 
658
599
  /**
659
- * Получить комментарии задачи.
600
+ * Get comments for task.
660
601
  */
661
602
  export function handleGetTaskComments({ task }) {
662
603
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -664,7 +605,7 @@ export function handleGetTaskComments({ task }) {
664
605
  }
665
606
 
666
607
  /**
667
- * Добавить комментарий к задаче.
608
+ * Add comment to task.
668
609
  */
669
610
  export function handleAddTaskComment({ store, task, input }) {
670
611
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -676,7 +617,7 @@ export function handleAddTaskComment({ store, task, input }) {
676
617
  }
677
618
 
678
619
  /**
679
- * Принять задачу на ревью (перевод в done).
620
+ * Accept task review (transition to done).
680
621
  */
681
622
  export function handleAcceptTask({ store, task }) {
682
623
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -685,7 +626,7 @@ export function handleAcceptTask({ store, task }) {
685
626
  }
686
627
 
687
628
  /**
688
- * Отклонить задачу на ревью (перевод обратно в in_progress).
629
+ * Reject task review (return to in_progress).
689
630
  */
690
631
  export function handleRejectTask({ store, task, input }) {
691
632
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
@@ -696,7 +637,7 @@ export function handleRejectTask({ store, task, input }) {
696
637
  }
697
638
 
698
639
  /**
699
- * Сохранить дамп прогресса задачи.
640
+ * Save task progress dump snapshot.
700
641
  */
701
642
  export function handleProgressDump({ store, task, input, formatProgressDump }) {
702
643
  if (!task) return { status: 404, body: { error: 'task-not-found' } }