@goodandready/dsh-kanban 0.2.2 → 0.2.8

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/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { registerPluginUpdater } from './updater.js'
1
2
  // dsh-kanban — доска задач для DeepSeek Harness.
2
3
  //
3
4
  // Плагин состоит из двух половин: этот модуль — серверная, `lib/client.js` —
@@ -9,21 +10,44 @@
9
10
  // `routes.js`) и проверяется тестами без харнесса. Здесь остаётся обвязка
10
11
  // cordis: настройки, хранилище и маршруты.
11
12
 
12
- import { homedir } from 'node:os'
13
- import { join } from 'node:path'
14
- import { randomUUID } from 'node:crypto'
15
- import z from '@deepseek-ai/schemastery'
16
- import { defineTool } from '@deepseek-ai/dsh-tools'
17
- import { SessionId } from '@deepseek-ai/dsh-session'
18
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
19
- import { CONFIG_DEFAULTS, CONFIG_HINTS, withDefaults, rootOf } from './config.js'
20
- import { openStore } from './store.js'
21
13
  import {
22
- MAX_BODY_BYTES, isTrustedRequest, parseTaskPath,
23
- buildBoard, applyMove, createTask, updateTask, deleteTask, appendNote, setArchived, listArchive,
24
- taskBody,
25
- taskLog, taskBySession, exportBoard, importBoard, exportTasksCsv, assignTask, boardMetrics,
14
+ MAX_BODY_BYTES,
15
+ isTrustedRequest,
16
+ parseTaskPath,
17
+ buildBoard,
18
+ applyMove,
19
+ createTask,
20
+ updateTask,
26
21
  confirmPermission,
22
+ appendNote,
23
+ setArchived,
24
+ listArchive,
25
+ assignTask,
26
+ deleteTask,
27
+ boardMetrics,
28
+ taskBody,
29
+ taskLog,
30
+ taskBySession,
31
+ exportBoard,
32
+ importBoard,
33
+ exportTasksCsv,
34
+ handleCleanupWorktree,
35
+ handleTaskDiff,
36
+ handleTaskCommits,
37
+ handleTaskMerge,
38
+ handleTaskWorktreeStatus,
39
+ handleTaskChecklist,
40
+ handleTaskReport,
41
+ handleGetTaskComments,
42
+ handleAddTaskComment,
43
+ handleAcceptTask,
44
+ handleRejectTask,
45
+ handleProgressDump,
46
+ handleTriageTask,
47
+ handleGetSubtasks,
48
+ handleCreateSubtask,
49
+ handleToggleChecklistItem,
50
+ handleCreateTaskPr,
27
51
  } from './routes.js'
28
52
  import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
29
53
  import {
@@ -34,6 +58,7 @@ import {
34
58
  import { cleanupTaskWorktree, checkWorktreeDirty, getTaskDiff, getTaskCommits, mergeTaskWorktree } from './worktree.js'
35
59
  import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
36
60
  import { createGiteaClient } from './gitea.js'
61
+ import { createKanbanTaskService } from './task-service.js'
37
62
  import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
38
63
  import { isWatched } from './intake.js'
39
64
  import { handleSessionEvent } from './lifecycle.js'
@@ -44,6 +69,8 @@ import {
44
69
  boardReportDefinition,
45
70
  boardCommentsDefinition,
46
71
  boardCommentAddDefinition,
72
+ boardDecomposeDefinition,
73
+ boardChecklistItemDefinition,
47
74
  } from './board-tool.js'
48
75
  import { verifySignature, parseEvent } from './webhook.js'
49
76
  import { planOutbound, planAssign, createOutbox } from './outbound.js'
@@ -51,13 +78,26 @@ import { computeNextRun } from './cron.js'
51
78
  import { PowerInhibitor } from './power.js'
52
79
  import { formatProgressDump, parseProgressDump } from './progress-dump.js'
53
80
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
81
+ import { defineTool } from '@deepseek-ai/dsh-tools'
82
+ import z from '@deepseek-ai/schemastery'
83
+ import { join } from 'node:path'
84
+ import { homedir } from 'node:os'
85
+ import { openStore } from './store.js'
86
+ import {
87
+ COLUMN_ORDER,
88
+ wipLimitField,
89
+ withDefaults,
90
+ CONFIG_DEFAULTS,
91
+ CONFIG_HINTS,
92
+ rootOf,
93
+ } from './config.js'
54
94
  import { eventHub } from './events.js'
55
95
  import { TASK_TEMPLATES } from './templates.js'
56
96
 
57
97
  export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
58
98
 
59
99
  /** Стабильное имя плагина cordis (строка в патче сборки). */
60
- export const name = 'dsh-kanban'
100
+ export const name = '@goodandready/dsh-kanban'
61
101
 
62
102
  /**
63
103
  * Службы добавляются там, где впервые нужны: `gitea` — вместе с импортом
@@ -97,14 +137,14 @@ export const Config = z.object(Object.fromEntries(
97
137
 
98
138
  /** Каталог хранилища. Абсолютных путей в коде нет — только переменная и дом. */
99
139
  export function storeDir() {
100
- return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'kanban')
140
+ return process.env.DSH_KANBAN_DIR || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'kanban')
101
141
  }
102
142
 
103
143
  function writeJson(res, code, body) {
104
144
  try {
105
145
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
106
146
  res.end(JSON.stringify(body))
107
- } catch { /* сокет закрыт */ }
147
+ } catch { /* socket closed */ }
108
148
  }
109
149
 
110
150
  /** Ответ обработчика: либо объект с `error` и `status`, либо полезная нагрузка. */
@@ -123,7 +163,7 @@ function readBody(req) {
123
163
  req.on('data', (c) => {
124
164
  size += c.length
125
165
  if (size > MAX_BODY_BYTES) {
126
- reject(Object.assign(new Error('тело запроса слишком велико'), { status: 413 }))
166
+ reject(Object.assign(new Error('Payload too large'), { status: 413 }))
127
167
  req.destroy()
128
168
  return
129
169
  }
@@ -132,7 +172,7 @@ function readBody(req) {
132
172
  req.on('end', () => {
133
173
  const raw = Buffer.concat(chunks).toString('utf8')
134
174
  if (raw.trim() === '') { resolve({}); return }
135
- try { resolve(JSON.parse(raw)) } catch { reject(Object.assign(new Error('тело запроса не JSON'), { status: 400 })) }
175
+ try { resolve(JSON.parse(raw)) } catch { reject(Object.assign(new Error('Malformed JSON payload'), { status: 400 })) }
136
176
  })
137
177
  req.on('error', reject)
138
178
  })
@@ -152,7 +192,7 @@ function readRaw(req) {
152
192
  req.on('data', (c) => {
153
193
  size += c.length
154
194
  if (size > MAX_BODY_BYTES) {
155
- reject(Object.assign(new Error('тело запроса слишком велико'), { status: 413 }))
195
+ reject(Object.assign(new Error('Payload too large'), { status: 413 }))
156
196
  req.destroy()
157
197
  return
158
198
  }
@@ -177,7 +217,7 @@ export function apply(ctx, config) {
177
217
  const rearmers = new Set()
178
218
  const rearm = () => {
179
219
  for (const again of rearmers) {
180
- try { again() } catch (error) { ctx.logger?.warn?.(`dsh-kanban: перевзвод не удался: ${error?.message}`) }
220
+ try { again() } catch (error) { ctx.logger?.warn?.(`dsh-kanban: rearm failed: ${error?.message}`) }
181
221
  }
182
222
  }
183
223
 
@@ -185,7 +225,7 @@ export function apply(ctx, config) {
185
225
  const scope = sctx.settings.register(SETTINGS_NAMESPACE, Config, { base: effective })
186
226
  const read = () => withDefaults(scope.get() ?? effective)
187
227
  effective = read()
188
- ctx.effect(() => scope.watch(() => { effective = read(); rearm() }), 'dsh-kanban: слежение за настройками')
228
+ ctx.effect(() => scope.watch(() => { effective = read(); rearm() }), 'dsh-kanban: settings watcher')
189
229
  })
190
230
 
191
231
  // Клиент Gitea свой: токен всё равно живёт в службе учётных данных ядра, а
@@ -209,10 +249,12 @@ export function apply(ctx, config) {
209
249
  // Хранилище открывается один раз и закрывается уборщиком того же эффекта:
210
250
  // выгрузка плагина обязана отпустить файл базы.
211
251
  let store
252
+ const taskService = createKanbanTaskService({ getStore: () => store })
253
+ if (typeof ctx.provide === 'function') ctx.provide('dshKanban', taskService)
212
254
  ctx.effect(() => {
213
- store = openStore({ dir: storeDir() })
214
- return () => { try { store.close() } catch { /* уже закрыта */ } }
215
- }, 'dsh-kanban: хранилище задач')
255
+ store = openStore({ dir: config?.storeDir || storeDir() })
256
+ return () => { try { store.close() } catch { /* already closed */ } }
257
+ }, 'dsh-kanban: task store')
216
258
 
217
259
  // Кроссплатформенный ингибитор сна системы во время работы задач
218
260
  const powerInhibitor = new PowerInhibitor({
@@ -228,19 +270,19 @@ export function apply(ctx, config) {
228
270
  const activeCount = store.countInColumn('main', 'in-progress') + store.countInColumn('simple', 'in-progress')
229
271
  powerInhibitor.setReasons(activeCount)
230
272
  }
231
- } catch { /* игнорируем */ }
273
+ } catch { /* ignore */ }
232
274
  }
233
275
  rearmers.add(syncPower)
234
276
  ctx.effect(() => {
235
277
  syncPower()
236
278
  return () => { powerInhibitor.destroy() }
237
- }, 'dsh-kanban: ингибитор сна')
279
+ }, 'dsh-kanban: power inhibitor')
238
280
 
239
281
  // Инъекция секции доски в системный промпт агента
240
282
  let disposePromptSection
241
283
  const syncPromptSection = () => {
242
284
  if (disposePromptSection) {
243
- try { disposePromptSection() } catch {}
285
+ try { disposePromptSection() } catch { /* cleanup best effort */ }
244
286
  disposePromptSection = undefined
245
287
  }
246
288
  const cfg = getConfig()
@@ -250,18 +292,18 @@ export function apply(ctx, config) {
250
292
  disposePromptSection = sp.section({
251
293
  name: 'plugin:dsh-kanban',
252
294
  order: 200,
253
- text: 'В системе развернута доска задач dsh-kanban. При работе над задачами вы можете перемещать карточки инструментом board_move и вести чек-лист плана через board_plan. При исчерпании контекста или завершении подэтапа сформируйте блок <<<PROGRESSDUMP\\nЦель: ...\\nПрогресс: ...\\nСледующие шаги: ...\\n>>>PROGRESSDUMP для передачи эстафеты следующей сессии.',
295
+ text: 'The dsh-kanban task board is available in the system. When working on tasks, you can move cards using the board_move tool and manage the plan checklist via board_plan. When context is running low or a milestone is reached, output a <<<PROGRESSDUMP\nGoal: ...\nProgress: ...\nNext steps: ...\n>>>PROGRESSDUMP block to hand over to the next session.',
254
296
  })
255
297
  } catch (err) {
256
- ctx.logger?.warn?.(`dsh-kanban: не удалось зарегистрировать секцию промпта: ${err?.message}`)
298
+ ctx.logger?.warn?.(`dsh-kanban: failed to register system prompt section: ${err?.message}`)
257
299
  }
258
300
  }
259
301
  }
260
302
  rearmers.add(syncPromptSection)
261
303
  ctx.effect(() => {
262
304
  syncPromptSection()
263
- return () => { if (disposePromptSection) { try { disposePromptSection() } catch {} } }
264
- }, 'dsh-kanban: секция системного промпта')
305
+ return () => { if (disposePromptSection) { try { disposePromptSection() } catch { /* cleanup best effort */ } } }
306
+ }, 'dsh-kanban: system prompt section')
265
307
 
266
308
  // Признак ожидания, исходы сессий и PROGRESSDUMP по событиям сессии.
267
309
  ctx.effect(() => ctx.on('session/event', (session, event) => {
@@ -281,10 +323,10 @@ export function apply(ctx, config) {
281
323
  let existingTask = store?.findTaskBySession?.(sessionIdStr)
282
324
  if (!existingTask && (event?.type === 'turn/start' || event?.type === 'session/created' || event?.type === 'input/user' || event?.type === 'message')) {
283
325
  const userText = (event?.content?.[0]?.text ?? event?.text ?? '').trim()
284
- const sessionTitle = session.title || session.name || (userText ? userText.slice(0, 50) : `Диалог ${sessionIdStr.slice(0, 8)}`)
326
+ const sessionTitle = session.title || session.name || (userText ? userText.slice(0, 50) : `Dialog ${sessionIdStr.slice(0, 8)}`)
285
327
  const createdTask = store.createTask({
286
328
  title: sessionTitle,
287
- body: userText || `Автоматически создано из сессии чата ${sessionIdStr}`,
329
+ body: userText || `Automatically created from chat session ${sessionIdStr}`,
288
330
  column: 'in-progress',
289
331
  board: 'main',
290
332
  sessionId: sessionIdStr,
@@ -334,9 +376,9 @@ export function apply(ctx, config) {
334
376
  }).catch(() => {})
335
377
  }
336
378
  } catch (error) {
337
- ctx.logger?.warn?.(`dsh-kanban: событие сессии не обработано: ${error?.message}`)
379
+ ctx.logger?.warn?.(`dsh-kanban: session event handler failed: ${error?.message}`)
338
380
  }
339
- }), 'dsh-kanban: события сессии')
381
+ }), 'dsh-kanban: session events')
340
382
 
341
383
  // Фоновый шедулер cron для задач
342
384
  ctx.effect(() => {
@@ -367,7 +409,7 @@ export function apply(ctx, config) {
367
409
  fromCol: task.column,
368
410
  toCol: task.column,
369
411
  source: 'tool',
370
- detail: 'cron: запуск отклонён (требуется подтверждение повышенных прав)',
412
+ detail: 'cron: run rejected (elevated permission required)',
371
413
  })
372
414
  continue
373
415
  }
@@ -396,7 +438,7 @@ export function apply(ctx, config) {
396
438
  store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
397
439
  }
398
440
  } catch (err) {
399
- ctx.logger?.warn?.(`dsh-kanban: ошибка шедулера cron: ${err?.message}`)
441
+ ctx.logger?.warn?.(`dsh-kanban: cron scheduler error: ${err?.message}`)
400
442
  }
401
443
  }, 30_000)
402
444
  if (typeof cronTimer.unref === 'function') cronTimer.unref()
@@ -404,7 +446,7 @@ export function apply(ctx, config) {
404
446
  armCron()
405
447
  rearmers.add(armCron)
406
448
  return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
407
- }, 'dsh-kanban: шедулер cron')
449
+ }, 'dsh-kanban: cron scheduler')
408
450
 
409
451
  // Периодическая сверка с Gitea. Ноль в настройках отключает её целиком, а
410
452
  // перекрытие запусков не допускается: медленный инстанс иначе копил бы
@@ -474,7 +516,7 @@ export function apply(ctx, config) {
474
516
  } catch (error) {
475
517
  // Сверка не начиналась вовсе — тоже беда, и своя: так выглядит
476
518
  // неразрешённый токен или отсутствующий клиент Gitea.
477
- syncState.failedToStart('сверка', error?.message)
519
+ syncState.failedToStart('sync', error?.message)
478
520
  throw error
479
521
  } finally {
480
522
  syncing = false
@@ -494,7 +536,7 @@ export function apply(ctx, config) {
494
536
  arm()
495
537
  rearmers.add(arm)
496
538
  return () => { rearmers.delete(arm); clearInterval(timer) }
497
- }, 'dsh-kanban: таймер сверки')
539
+ }, 'dsh-kanban: sync timer')
498
540
 
499
541
  ctx.effect(() => ctx.webServer.register({
500
542
  kind: 'exact',
@@ -515,7 +557,7 @@ export function apply(ctx, config) {
515
557
  writeJson(res, error?.status ?? 500, { error: 'bad-request' })
516
558
  }
517
559
  },
518
- }), 'dsh-kanban: проектная задача')
560
+ }), 'dsh-kanban: project task')
519
561
 
520
562
  ctx.effect(() => ctx.webServer.register({
521
563
  kind: 'exact',
@@ -535,7 +577,7 @@ export function apply(ctx, config) {
535
577
  id: row.id, name: row.name || row.id, description: row.description,
536
578
  }))
537
579
  } catch (error) {
538
- ctx.logger?.warn?.(`dsh-kanban: профили агента не прочитались: ${error?.message}`)
580
+ ctx.logger?.warn?.(`dsh-kanban: agent profiles failed to load: ${error?.message}`)
539
581
  }
540
582
  let access = []
541
583
  let accessDefault = ''
@@ -545,7 +587,7 @@ export function apply(ctx, config) {
545
587
  accessDefault = permissions.defaultPreset ?? ''
546
588
  }
547
589
  } catch (error) {
548
- ctx.logger?.warn?.(`dsh-kanban: уровни доступа не прочитались: ${error?.message}`)
590
+ ctx.logger?.warn?.(`dsh-kanban: access levels failed to load: ${error?.message}`)
549
591
  }
550
592
  reply(res, {
551
593
  agentPresets,
@@ -554,13 +596,13 @@ export function apply(ctx, config) {
554
596
  accessDefault,
555
597
  })
556
598
  },
557
- }), 'dsh-kanban: профили агента и уровни доступа')
599
+ }), 'dsh-kanban: agent profiles and auth scopes')
558
600
 
559
601
  ctx.effect(() => ctx.webServer.register({
560
602
  kind: 'exact',
561
603
  path: '/dsh-kanban/sessions',
562
604
  handler: (req, res) => { reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) }) },
563
- }), 'dsh-kanban: живые сессии')
605
+ }), 'dsh-kanban: live sessions')
564
606
 
565
607
  ctx.effect(() => ctx.webServer.register({
566
608
  kind: 'exact',
@@ -592,13 +634,13 @@ export function apply(ctx, config) {
592
634
  writeJson(res, error?.status ?? 500, { error: 'batch-failed' })
593
635
  }
594
636
  },
595
- }), 'dsh-kanban: групповой запуск')
637
+ }), 'dsh-kanban: batch dispatch')
596
638
 
597
639
  ctx.effect(() => ctx.webServer.register({
598
640
  kind: 'exact',
599
641
  path: '/dsh-kanban/archive',
600
642
  handler: (req, res) => { reply(res, listArchive({ store })) },
601
- }), 'dsh-kanban: архив')
643
+ }), 'dsh-kanban: archive')
602
644
 
603
645
  ctx.effect(() => ctx.webServer.register({
604
646
  kind: 'exact',
@@ -612,7 +654,7 @@ export function apply(ctx, config) {
612
654
  writeJson(res, 502, { error: 'sync-failed' })
613
655
  }
614
656
  },
615
- }), 'dsh-kanban: ручная сверка')
657
+ }), 'dsh-kanban: manual sync')
616
658
 
617
659
  ctx.effect(() => ctx.webServer.register({
618
660
  kind: 'exact',
@@ -664,7 +706,7 @@ export function apply(ctx, config) {
664
706
  writeJson(res, error?.status ?? 500, { error: 'webhook-failed' })
665
707
  }
666
708
  },
667
- }), 'dsh-kanban: вебхук Gitea')
709
+ }), 'dsh-kanban: gitea webhook')
668
710
 
669
711
  // Инструмент для агента. По умолчанию ВЫКЛЮЧЕН: скилл воркфлоу пока
670
712
  // запрещает этому CLI трогать канбан, и включить инструмент раньше правки
@@ -688,6 +730,8 @@ export function apply(ctx, config) {
688
730
  ctx.tools.register(defineTool(boardReportDefinition({ store }))),
689
731
  ctx.tools.register(defineTool(boardCommentsDefinition({ store }))),
690
732
  ctx.tools.register(defineTool(boardCommentAddDefinition({ store }))),
733
+ ctx.tools.register(defineTool(boardDecomposeDefinition({ store, gitea }))),
734
+ ctx.tools.register(defineTool(boardChecklistItemDefinition({ store }))),
691
735
  ]
692
736
  return () => { for (const stop of off) stop?.() }
693
737
  }
@@ -695,7 +739,7 @@ export function apply(ctx, config) {
695
739
  arm()
696
740
  rearmers.add(arm)
697
741
  return () => { rearmers.delete(arm); unregister?.() }
698
- }, 'dsh-kanban: инструмент доски')
742
+ }, 'dsh-kanban: board tool')
699
743
 
700
744
  /**
701
745
  * Состояние живого агента сессии либо `undefined`, если агента нет.
@@ -721,7 +765,7 @@ export function apply(ctx, config) {
721
765
  projectRoot: rootOf(getConfig()),
722
766
  }))
723
767
  },
724
- }), 'dsh-kanban: маршрут доски')
768
+ }), 'dsh-kanban: board route')
725
769
 
726
770
  // Снимок доски — ОДИН ресурс с двумя действиями, а не две ручки.
727
771
  //
@@ -760,7 +804,7 @@ export function apply(ctx, config) {
760
804
  staleDays: Number.isFinite(days) && days > 0 ? days : 14,
761
805
  }))
762
806
  },
763
- }), 'dsh-kanban: метрики доски')
807
+ }), 'dsh-kanban: board metrics')
764
808
 
765
809
  ctx.effect(() => ctx.webServer.register({
766
810
  kind: 'exact',
@@ -768,7 +812,7 @@ export function apply(ctx, config) {
768
812
  // Пустой логин — законный ответ: Gitea может быть не настроен или не
769
813
  // отвечать. Браузер по нему просто не рисует «Мои», а не показывает ноль.
770
814
  handler: async (req, res) => { reply(res, { login: await whoAmI() }) },
771
- }), 'dsh-kanban: кто мы для Gitea')
815
+ }), 'dsh-kanban: whoami')
772
816
 
773
817
  ctx.effect(() => ctx.webServer.register({
774
818
  kind: 'exact',
@@ -784,7 +828,7 @@ export function apply(ctx, config) {
784
828
  })
785
829
  res.end(csv)
786
830
  },
787
- }), 'dsh-kanban: экспорт в CSV')
831
+ }), 'dsh-kanban: csv export')
788
832
 
789
833
  ctx.effect(() => ctx.webServer.register({
790
834
  kind: 'exact',
@@ -792,7 +836,7 @@ export function apply(ctx, config) {
792
836
  handler: (req, res) => {
793
837
  eventHub.handleSseRequest(req, res)
794
838
  },
795
- }), 'dsh-kanban: поток живых событий SSE')
839
+ }), 'dsh-kanban: sse events stream')
796
840
 
797
841
  ctx.effect(() => ctx.webServer.register({
798
842
  kind: 'exact',
@@ -800,7 +844,7 @@ export function apply(ctx, config) {
800
844
  handler: (req, res) => {
801
845
  reply(res, { templates: TASK_TEMPLATES })
802
846
  },
803
- }), 'dsh-kanban: шаблоны задач')
847
+ }), 'dsh-kanban: task templates')
804
848
 
805
849
  ctx.effect(() => ctx.webServer.register({
806
850
  kind: 'exact',
@@ -816,7 +860,7 @@ export function apply(ctx, config) {
816
860
  writeJson(res, 400, { error: 'bad-request' })
817
861
  }
818
862
  },
819
- }), 'dsh-kanban: снимок доски')
863
+ }), 'dsh-kanban: board snapshot')
820
864
 
821
865
  ctx.effect(() => ctx.webServer.register({
822
866
  // Префикс регистрируется БЕЗ завершающего слэша: со слэшем сопоставление
@@ -902,7 +946,7 @@ export function apply(ctx, config) {
902
946
  syncPower()
903
947
  reply(res, started)
904
948
  } catch (error) {
905
- ctx.logger?.warn?.(`dsh-kanban: запуск задачи не удался: ${error?.message}`)
949
+ ctx.logger?.warn?.(`dsh-kanban: task launch failed: ${error?.message}`)
906
950
  // Отказ по уровню доступа называется своим именем: «не удалось» на
907
951
  // месте «сессия пошла бы с большими правами» — это молчание.
908
952
  writeJson(res, error?.status ?? 500, { error: error?.key ?? 'start-failed' })
@@ -949,112 +993,93 @@ export function apply(ctx, config) {
949
993
  return
950
994
  }
951
995
  if (req.method === 'POST' && action === 'cleanup-worktree') {
952
- const task = store.getTask(id)
953
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
954
- const out = await cleanupTaskWorktree({
955
- store,
956
- task,
996
+ const resObj = await handleCleanupWorktree({
997
+ store, task: store.getTask(id),
957
998
  workspaceRegistry: ctx.get('workspaceRegistry'),
958
999
  force: input?.force === true,
1000
+ cleanupTaskWorktree,
959
1001
  })
960
- reply(res, out)
1002
+ writeJson(res, resObj.status, resObj.body)
961
1003
  return
962
1004
  }
963
1005
  if (req.method === 'GET' && action === 'diff') {
964
- const task = store.getTask(id)
965
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
966
- const diffResult = await getTaskDiff({ worktreePath: task.worktree })
967
- reply(res, diffResult)
1006
+ const resObj = await handleTaskDiff({ task: store.getTask(id), getTaskDiff })
1007
+ writeJson(res, resObj.status, resObj.body)
968
1008
  return
969
1009
  }
970
1010
  if (req.method === 'GET' && action === 'commits') {
971
- const task = store.getTask(id)
972
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
973
- const commits = await getTaskCommits({ worktreePath: task.worktree })
974
- reply(res, { commits })
1011
+ const resObj = await handleTaskCommits({ task: store.getTask(id), getTaskCommits })
1012
+ writeJson(res, resObj.status, resObj.body)
975
1013
  return
976
1014
  }
977
1015
  if (req.method === 'POST' && action === 'merge') {
978
- const task = store.getTask(id)
979
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
980
- const mergeResult = await mergeTaskWorktree({
981
- task,
982
- targetBranch: typeof input?.targetBranch === 'string' ? input.targetBranch.trim() : undefined,
1016
+ const resObj = await handleTaskMerge({
1017
+ store, task: store.getTask(id),
1018
+ targetBranch: input?.targetBranch,
983
1019
  workspaceRegistry: ctx.get('workspaceRegistry'),
1020
+ mergeTaskWorktree,
984
1021
  })
985
- if (!mergeResult.success) {
986
- writeJson(res, 400, { error: mergeResult.error, detail: mergeResult.detail, files: mergeResult.files })
987
- return
988
- }
989
- const updated = store.acceptTask(id)
990
- store.addTransition({
991
- taskId: id,
992
- fromCol: task.column,
993
- toCol: 'done',
994
- source: 'manual',
995
- detail: `принята и смержена ветка ${task.branch} в ${mergeResult.targetBranch}`,
996
- })
997
- reply(res, { task: updated, merged: true, targetBranch: mergeResult.targetBranch })
1022
+ writeJson(res, resObj.status, resObj.body)
998
1023
  return
999
1024
  }
1000
1025
  if (req.method === 'GET' && action === 'worktree-status') {
1001
- const task = store.getTask(id)
1002
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1003
- const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
1004
- reply(res, {
1005
- worktree: task.worktree || '',
1006
- branch: task.branch || '',
1007
- workspaceId: task.workspaceId || '',
1008
- dirty: dirtyCheck.dirty,
1009
- files: dirtyCheck.files,
1010
- })
1026
+ const resObj = await handleTaskWorktreeStatus({ task: store.getTask(id), checkWorktreeDirty })
1027
+ writeJson(res, resObj.status, resObj.body)
1011
1028
  return
1012
1029
  }
1013
- if (req.method === 'POST' && action === 'checklist') {
1014
- const task = store.getTask(id)
1015
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1016
- const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
1017
- const updated = store.updateChecklist(id, list)
1018
- reply(res, { task: updated })
1030
+ if (req.method === 'GET' && action === 'subtasks') {
1031
+ const resObj = handleGetSubtasks({ store, task: store.getTask(id) })
1032
+ writeJson(res, resObj.status, resObj.body)
1033
+ return
1034
+ }
1035
+ if (req.method === 'POST' && action === 'triage') {
1036
+ const resObj = await handleTriageTask({ store, gitea, task: store.getTask(id), input })
1037
+ writeJson(res, resObj.status, resObj.body)
1038
+ return
1039
+ }
1040
+ if (req.method === 'POST' && (action === 'subtask' || action === 'subtasks')) {
1041
+ const resObj = await handleCreateSubtask({ store, gitea, task: store.getTask(id), input })
1042
+ writeJson(res, resObj.status, resObj.body)
1043
+ return
1044
+ }
1045
+ if (req.method === 'POST' && action === 'checklist-toggle') {
1046
+ const resObj = handleToggleChecklistItem({ store, task: store.getTask(id), input })
1047
+ writeJson(res, resObj.status, resObj.body)
1048
+ return
1049
+ }
1050
+ if (req.method === 'POST' && action === 'pr') {
1051
+ const resObj = await handleCreateTaskPr({ store, gitea, task: store.getTask(id), input })
1052
+ writeJson(res, resObj.status, resObj.body)
1053
+ return
1054
+ }
1055
+ if (req.method === 'POST' && action === 'checklist') {
1056
+ const resObj = handleTaskChecklist({ store, task: store.getTask(id), input })
1057
+ writeJson(res, resObj.status, resObj.body)
1019
1058
  return
1020
1059
  }
1021
1060
  if (req.method === 'POST' && action === 'report') {
1022
- const task = store.getTask(id)
1023
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1024
- const updated = store.setReport(id, input)
1025
- reply(res, { task: updated })
1061
+ const resObj = handleTaskReport({ store, task: store.getTask(id), input })
1062
+ writeJson(res, resObj.status, resObj.body)
1026
1063
  return
1027
1064
  }
1028
1065
  if (req.method === 'GET' && action === 'comments') {
1029
- const task = store.getTask(id)
1030
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1031
- reply(res, { comments: task.comments || [] })
1066
+ const resObj = handleGetTaskComments({ task: store.getTask(id) })
1067
+ writeJson(res, resObj.status, resObj.body)
1032
1068
  return
1033
1069
  }
1034
1070
  if (req.method === 'POST' && action === 'comments') {
1035
- const task = store.getTask(id)
1036
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1037
- const author = input?.author || 'user'
1038
- const role = input?.role || 'user'
1039
- const text = input?.text || ''
1040
- const updated = store.addComment(id, { author, role, text })
1041
- reply(res, { task: updated })
1071
+ const resObj = handleAddTaskComment({ store, task: store.getTask(id), input })
1072
+ writeJson(res, resObj.status, resObj.body)
1042
1073
  return
1043
1074
  }
1044
1075
  if (req.method === 'POST' && action === 'accept') {
1045
- const task = store.getTask(id)
1046
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1047
- const updated = store.acceptTask(id)
1048
- reply(res, { task: updated })
1076
+ const resObj = handleAcceptTask({ store, task: store.getTask(id) })
1077
+ writeJson(res, resObj.status, resObj.body)
1049
1078
  return
1050
1079
  }
1051
1080
  if (req.method === 'POST' && action === 'reject') {
1052
- const task = store.getTask(id)
1053
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1054
- const reason = String(input?.reason || '').trim()
1055
- if (!reason) { writeJson(res, 400, { error: 'reason-required' }); return }
1056
- const updated = store.rejectTask(id, reason)
1057
- reply(res, { task: updated })
1081
+ const resObj = handleRejectTask({ store, task: store.getTask(id), input })
1082
+ writeJson(res, resObj.status, resObj.body)
1058
1083
  return
1059
1084
  }
1060
1085
  if (req.method === 'POST' && action === 'resume') {
@@ -1080,7 +1105,7 @@ export function apply(ctx, config) {
1080
1105
  text: input?.text,
1081
1106
  agentPreset: input?.agentPreset || task.agentPreset,
1082
1107
  permission: input?.permission || task.permission,
1083
- permissions: ctx.permissions,
1108
+ permissions: ctx.get('permissionPresets'),
1084
1109
  claimAs: req.headers['x-dsh-user'],
1085
1110
  gitRunner,
1086
1111
  workspaceRegistry: ctx.get('workspaceRegistry'),
@@ -1095,11 +1120,8 @@ export function apply(ctx, config) {
1095
1120
  return
1096
1121
  }
1097
1122
  if (req.method === 'POST' && action === 'progress-dump') {
1098
- const task = store.getTask(id)
1099
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1100
- const formatted = typeof input === 'string' ? input : formatProgressDump(input)
1101
- const updated = store.updateTask(id, { progressDump: formatted })
1102
- reply(res, { task: updated })
1123
+ const resObj = handleProgressDump({ store, task: store.getTask(id), input, formatProgressDump })
1124
+ writeJson(res, resObj.status, resObj.body)
1103
1125
  return
1104
1126
  }
1105
1127
  if (req.method === 'POST' && action === 'stop') {
@@ -1174,7 +1196,7 @@ export function apply(ctx, config) {
1174
1196
  writeJson(res, error?.status ?? 500, { error: 'bad-request' })
1175
1197
  }
1176
1198
  },
1177
- }), 'dsh-kanban: маршруты задачи')
1199
+ }), 'dsh-kanban: task routes')
1178
1200
 
1179
1201
  // Готовность Gitea проверяется на каждом запросе, а не при загрузке: адрес и
1180
1202
  // учётная запись правятся в карточке настроек на ходу, и маршруты обязаны
@@ -1186,7 +1208,7 @@ export function apply(ctx, config) {
1186
1208
  const url = urlOf(req)
1187
1209
  reply(res, await searchRepos({ gitea, query: url.searchParams.get('q') || '' }))
1188
1210
  },
1189
- }), 'dsh-kanban: поиск репозиториев')
1211
+ }), 'dsh-kanban: repo search')
1190
1212
 
1191
1213
  ctx.effect(() => ctx.webServer.register({
1192
1214
  kind: 'exact',
@@ -1201,7 +1223,7 @@ export function apply(ctx, config) {
1201
1223
  board: url.searchParams.get('board') || 'main',
1202
1224
  }))
1203
1225
  },
1204
- }), 'dsh-kanban: список issue для импорта')
1226
+ }), 'dsh-kanban: importable issues')
1205
1227
 
1206
1228
  ctx.effect(() => ctx.webServer.register({
1207
1229
  kind: 'exact',
@@ -1220,7 +1242,7 @@ export function apply(ctx, config) {
1220
1242
  writeJson(res, error?.status ?? 500, { error: 'bad-request' })
1221
1243
  }
1222
1244
  },
1223
- }), 'dsh-kanban: импорт issue')
1245
+ }), 'dsh-kanban: issue import')
1224
1246
 
1225
1247
  // Провайдеры отдаются сразу, модели — только для запрошенного провайдера:
1226
1248
  // перечень моделей провайдер отдаёт по сети, и опрашивать все разом ради
@@ -1251,7 +1273,7 @@ export function apply(ctx, config) {
1251
1273
  writeJson(res, 200, { current, providers, models: [] })
1252
1274
  }
1253
1275
  },
1254
- }), 'dsh-kanban: провайдеры и модели')
1276
+ }), 'dsh-kanban: providers and models')
1255
1277
 
1256
1278
  ctx.effect(() => ctx.webServer.register({
1257
1279
  kind: 'prefix',
@@ -1261,5 +1283,12 @@ export function apply(ctx, config) {
1261
1283
  if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
1262
1284
  reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]) }))
1263
1285
  },
1264
- }), 'dsh-kanban: маршрут задачи по сессии')
1286
+ }), 'dsh-kanban: task route by session')
1287
+
1288
+ ctx.effect(() => registerPluginUpdater(ctx, {
1289
+ endpoint: '/api/dsh-kanban/update',
1290
+ packageName: '@goodandready/dsh-kanban',
1291
+ manifestUrl: new URL('../package.json', import.meta.url),
1292
+ }), 'dsh-kanban: plugin updater route')
1293
+
1265
1294
  }