@goodandready/dsh-kanban 0.2.7 → 0.2.9

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/filters.js CHANGED
@@ -41,7 +41,7 @@ export const NOBODY = '—'
41
41
  export const NO_MILESTONE = '—'
42
42
 
43
43
  /** Порядок приоритетов в отборе: срочное сверху. */
44
- export const PRIORITY_ORDER = ['critical', 'high', 'medium', 'low']
44
+ const PRIORITY_ORDER = ['critical', 'high', 'medium', 'low']
45
45
 
46
46
  /**
47
47
  * Порядок известных пространств.
@@ -279,7 +279,7 @@ export function liveRank(task) {
279
279
  }
280
280
 
281
281
  /** Вес приоритета при подъёме: срочное впереди тихого. */
282
- export function priorityWeight(priority) {
282
+ function priorityWeight(priority) {
283
283
  const at = PRIORITY_ORDER.indexOf(priority)
284
284
  return at === -1 ? PRIORITY_ORDER.length : at
285
285
  }
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` —
@@ -57,6 +58,7 @@ import {
57
58
  import { cleanupTaskWorktree, checkWorktreeDirty, getTaskDiff, getTaskCommits, mergeTaskWorktree } from './worktree.js'
58
59
  import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
59
60
  import { createGiteaClient } from './gitea.js'
61
+ import { createKanbanTaskService } from './task-service.js'
60
62
  import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
61
63
  import { isWatched } from './intake.js'
62
64
  import { handleSessionEvent } from './lifecycle.js'
@@ -95,7 +97,7 @@ import { TASK_TEMPLATES } from './templates.js'
95
97
  export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
96
98
 
97
99
  /** Стабильное имя плагина cordis (строка в патче сборки). */
98
- export const name = 'dsh-kanban'
100
+ export const name = '@goodandready/dsh-kanban'
99
101
 
100
102
  /**
101
103
  * Службы добавляются там, где впервые нужны: `gitea` — вместе с импортом
@@ -135,14 +137,14 @@ export const Config = z.object(Object.fromEntries(
135
137
 
136
138
  /** Каталог хранилища. Абсолютных путей в коде нет — только переменная и дом. */
137
139
  export function storeDir() {
138
- 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')
139
141
  }
140
142
 
141
143
  function writeJson(res, code, body) {
142
144
  try {
143
145
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
144
146
  res.end(JSON.stringify(body))
145
- } catch { /* сокет закрыт */ }
147
+ } catch { /* socket closed */ }
146
148
  }
147
149
 
148
150
  /** Ответ обработчика: либо объект с `error` и `status`, либо полезная нагрузка. */
@@ -161,7 +163,7 @@ function readBody(req) {
161
163
  req.on('data', (c) => {
162
164
  size += c.length
163
165
  if (size > MAX_BODY_BYTES) {
164
- reject(Object.assign(new Error('тело запроса слишком велико'), { status: 413 }))
166
+ reject(Object.assign(new Error('Payload too large'), { status: 413 }))
165
167
  req.destroy()
166
168
  return
167
169
  }
@@ -170,7 +172,7 @@ function readBody(req) {
170
172
  req.on('end', () => {
171
173
  const raw = Buffer.concat(chunks).toString('utf8')
172
174
  if (raw.trim() === '') { resolve({}); return }
173
- 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 })) }
174
176
  })
175
177
  req.on('error', reject)
176
178
  })
@@ -190,7 +192,7 @@ function readRaw(req) {
190
192
  req.on('data', (c) => {
191
193
  size += c.length
192
194
  if (size > MAX_BODY_BYTES) {
193
- reject(Object.assign(new Error('тело запроса слишком велико'), { status: 413 }))
195
+ reject(Object.assign(new Error('Payload too large'), { status: 413 }))
194
196
  req.destroy()
195
197
  return
196
198
  }
@@ -215,7 +217,7 @@ export function apply(ctx, config) {
215
217
  const rearmers = new Set()
216
218
  const rearm = () => {
217
219
  for (const again of rearmers) {
218
- 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}`) }
219
221
  }
220
222
  }
221
223
 
@@ -223,7 +225,7 @@ export function apply(ctx, config) {
223
225
  const scope = sctx.settings.register(SETTINGS_NAMESPACE, Config, { base: effective })
224
226
  const read = () => withDefaults(scope.get() ?? effective)
225
227
  effective = read()
226
- ctx.effect(() => scope.watch(() => { effective = read(); rearm() }), 'dsh-kanban: слежение за настройками')
228
+ ctx.effect(() => scope.watch(() => { effective = read(); rearm() }), 'dsh-kanban: settings watcher')
227
229
  })
228
230
 
229
231
  // Клиент Gitea свой: токен всё равно живёт в службе учётных данных ядра, а
@@ -247,10 +249,12 @@ export function apply(ctx, config) {
247
249
  // Хранилище открывается один раз и закрывается уборщиком того же эффекта:
248
250
  // выгрузка плагина обязана отпустить файл базы.
249
251
  let store
252
+ const taskService = createKanbanTaskService({ getStore: () => store })
253
+ if (typeof ctx.provide === 'function') ctx.provide('dshKanban', taskService)
250
254
  ctx.effect(() => {
251
- store = openStore({ dir: storeDir() })
252
- return () => { try { store.close() } catch { /* уже закрыта */ } }
253
- }, 'dsh-kanban: хранилище задач')
255
+ store = openStore({ dir: config?.storeDir || storeDir() })
256
+ return () => { try { store.close() } catch { /* already closed */ } }
257
+ }, 'dsh-kanban: task store')
254
258
 
255
259
  // Кроссплатформенный ингибитор сна системы во время работы задач
256
260
  const powerInhibitor = new PowerInhibitor({
@@ -266,19 +270,19 @@ export function apply(ctx, config) {
266
270
  const activeCount = store.countInColumn('main', 'in-progress') + store.countInColumn('simple', 'in-progress')
267
271
  powerInhibitor.setReasons(activeCount)
268
272
  }
269
- } catch { /* игнорируем */ }
273
+ } catch { /* ignore */ }
270
274
  }
271
275
  rearmers.add(syncPower)
272
276
  ctx.effect(() => {
273
277
  syncPower()
274
278
  return () => { powerInhibitor.destroy() }
275
- }, 'dsh-kanban: ингибитор сна')
279
+ }, 'dsh-kanban: power inhibitor')
276
280
 
277
281
  // Инъекция секции доски в системный промпт агента
278
282
  let disposePromptSection
279
283
  const syncPromptSection = () => {
280
284
  if (disposePromptSection) {
281
- try { disposePromptSection() } catch {}
285
+ try { disposePromptSection() } catch { /* cleanup best effort */ }
282
286
  disposePromptSection = undefined
283
287
  }
284
288
  const cfg = getConfig()
@@ -288,18 +292,18 @@ export function apply(ctx, config) {
288
292
  disposePromptSection = sp.section({
289
293
  name: 'plugin:dsh-kanban',
290
294
  order: 200,
291
- 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.',
292
296
  })
293
297
  } catch (err) {
294
- ctx.logger?.warn?.(`dsh-kanban: не удалось зарегистрировать секцию промпта: ${err?.message}`)
298
+ ctx.logger?.warn?.(`dsh-kanban: failed to register system prompt section: ${err?.message}`)
295
299
  }
296
300
  }
297
301
  }
298
302
  rearmers.add(syncPromptSection)
299
303
  ctx.effect(() => {
300
304
  syncPromptSection()
301
- return () => { if (disposePromptSection) { try { disposePromptSection() } catch {} } }
302
- }, 'dsh-kanban: секция системного промпта')
305
+ return () => { if (disposePromptSection) { try { disposePromptSection() } catch { /* cleanup best effort */ } } }
306
+ }, 'dsh-kanban: system prompt section')
303
307
 
304
308
  // Признак ожидания, исходы сессий и PROGRESSDUMP по событиям сессии.
305
309
  ctx.effect(() => ctx.on('session/event', (session, event) => {
@@ -319,10 +323,10 @@ export function apply(ctx, config) {
319
323
  let existingTask = store?.findTaskBySession?.(sessionIdStr)
320
324
  if (!existingTask && (event?.type === 'turn/start' || event?.type === 'session/created' || event?.type === 'input/user' || event?.type === 'message')) {
321
325
  const userText = (event?.content?.[0]?.text ?? event?.text ?? '').trim()
322
- 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)}`)
323
327
  const createdTask = store.createTask({
324
328
  title: sessionTitle,
325
- body: userText || `Автоматически создано из сессии чата ${sessionIdStr}`,
329
+ body: userText || `Automatically created from chat session ${sessionIdStr}`,
326
330
  column: 'in-progress',
327
331
  board: 'main',
328
332
  sessionId: sessionIdStr,
@@ -372,9 +376,9 @@ export function apply(ctx, config) {
372
376
  }).catch(() => {})
373
377
  }
374
378
  } catch (error) {
375
- ctx.logger?.warn?.(`dsh-kanban: событие сессии не обработано: ${error?.message}`)
379
+ ctx.logger?.warn?.(`dsh-kanban: session event handler failed: ${error?.message}`)
376
380
  }
377
- }), 'dsh-kanban: события сессии')
381
+ }), 'dsh-kanban: session events')
378
382
 
379
383
  // Фоновый шедулер cron для задач
380
384
  ctx.effect(() => {
@@ -405,7 +409,7 @@ export function apply(ctx, config) {
405
409
  fromCol: task.column,
406
410
  toCol: task.column,
407
411
  source: 'tool',
408
- detail: 'cron: запуск отклонён (требуется подтверждение повышенных прав)',
412
+ detail: 'cron: run rejected (elevated permission required)',
409
413
  })
410
414
  continue
411
415
  }
@@ -434,7 +438,7 @@ export function apply(ctx, config) {
434
438
  store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
435
439
  }
436
440
  } catch (err) {
437
- ctx.logger?.warn?.(`dsh-kanban: ошибка шедулера cron: ${err?.message}`)
441
+ ctx.logger?.warn?.(`dsh-kanban: cron scheduler error: ${err?.message}`)
438
442
  }
439
443
  }, 30_000)
440
444
  if (typeof cronTimer.unref === 'function') cronTimer.unref()
@@ -442,7 +446,7 @@ export function apply(ctx, config) {
442
446
  armCron()
443
447
  rearmers.add(armCron)
444
448
  return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
445
- }, 'dsh-kanban: шедулер cron')
449
+ }, 'dsh-kanban: cron scheduler')
446
450
 
447
451
  // Периодическая сверка с Gitea. Ноль в настройках отключает её целиком, а
448
452
  // перекрытие запусков не допускается: медленный инстанс иначе копил бы
@@ -512,7 +516,7 @@ export function apply(ctx, config) {
512
516
  } catch (error) {
513
517
  // Сверка не начиналась вовсе — тоже беда, и своя: так выглядит
514
518
  // неразрешённый токен или отсутствующий клиент Gitea.
515
- syncState.failedToStart('сверка', error?.message)
519
+ syncState.failedToStart('sync', error?.message)
516
520
  throw error
517
521
  } finally {
518
522
  syncing = false
@@ -532,7 +536,7 @@ export function apply(ctx, config) {
532
536
  arm()
533
537
  rearmers.add(arm)
534
538
  return () => { rearmers.delete(arm); clearInterval(timer) }
535
- }, 'dsh-kanban: таймер сверки')
539
+ }, 'dsh-kanban: sync timer')
536
540
 
537
541
  ctx.effect(() => ctx.webServer.register({
538
542
  kind: 'exact',
@@ -553,7 +557,7 @@ export function apply(ctx, config) {
553
557
  writeJson(res, error?.status ?? 500, { error: 'bad-request' })
554
558
  }
555
559
  },
556
- }), 'dsh-kanban: проектная задача')
560
+ }), 'dsh-kanban: project task')
557
561
 
558
562
  ctx.effect(() => ctx.webServer.register({
559
563
  kind: 'exact',
@@ -573,7 +577,7 @@ export function apply(ctx, config) {
573
577
  id: row.id, name: row.name || row.id, description: row.description,
574
578
  }))
575
579
  } catch (error) {
576
- ctx.logger?.warn?.(`dsh-kanban: профили агента не прочитались: ${error?.message}`)
580
+ ctx.logger?.warn?.(`dsh-kanban: agent profiles failed to load: ${error?.message}`)
577
581
  }
578
582
  let access = []
579
583
  let accessDefault = ''
@@ -583,7 +587,7 @@ export function apply(ctx, config) {
583
587
  accessDefault = permissions.defaultPreset ?? ''
584
588
  }
585
589
  } catch (error) {
586
- ctx.logger?.warn?.(`dsh-kanban: уровни доступа не прочитались: ${error?.message}`)
590
+ ctx.logger?.warn?.(`dsh-kanban: access levels failed to load: ${error?.message}`)
587
591
  }
588
592
  reply(res, {
589
593
  agentPresets,
@@ -592,13 +596,13 @@ export function apply(ctx, config) {
592
596
  accessDefault,
593
597
  })
594
598
  },
595
- }), 'dsh-kanban: профили агента и уровни доступа')
599
+ }), 'dsh-kanban: agent profiles and auth scopes')
596
600
 
597
601
  ctx.effect(() => ctx.webServer.register({
598
602
  kind: 'exact',
599
603
  path: '/dsh-kanban/sessions',
600
604
  handler: (req, res) => { reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) }) },
601
- }), 'dsh-kanban: живые сессии')
605
+ }), 'dsh-kanban: live sessions')
602
606
 
603
607
  ctx.effect(() => ctx.webServer.register({
604
608
  kind: 'exact',
@@ -630,13 +634,13 @@ export function apply(ctx, config) {
630
634
  writeJson(res, error?.status ?? 500, { error: 'batch-failed' })
631
635
  }
632
636
  },
633
- }), 'dsh-kanban: групповой запуск')
637
+ }), 'dsh-kanban: batch dispatch')
634
638
 
635
639
  ctx.effect(() => ctx.webServer.register({
636
640
  kind: 'exact',
637
641
  path: '/dsh-kanban/archive',
638
642
  handler: (req, res) => { reply(res, listArchive({ store })) },
639
- }), 'dsh-kanban: архив')
643
+ }), 'dsh-kanban: archive')
640
644
 
641
645
  ctx.effect(() => ctx.webServer.register({
642
646
  kind: 'exact',
@@ -650,7 +654,7 @@ export function apply(ctx, config) {
650
654
  writeJson(res, 502, { error: 'sync-failed' })
651
655
  }
652
656
  },
653
- }), 'dsh-kanban: ручная сверка')
657
+ }), 'dsh-kanban: manual sync')
654
658
 
655
659
  ctx.effect(() => ctx.webServer.register({
656
660
  kind: 'exact',
@@ -702,7 +706,7 @@ export function apply(ctx, config) {
702
706
  writeJson(res, error?.status ?? 500, { error: 'webhook-failed' })
703
707
  }
704
708
  },
705
- }), 'dsh-kanban: вебхук Gitea')
709
+ }), 'dsh-kanban: gitea webhook')
706
710
 
707
711
  // Инструмент для агента. По умолчанию ВЫКЛЮЧЕН: скилл воркфлоу пока
708
712
  // запрещает этому CLI трогать канбан, и включить инструмент раньше правки
@@ -735,7 +739,7 @@ export function apply(ctx, config) {
735
739
  arm()
736
740
  rearmers.add(arm)
737
741
  return () => { rearmers.delete(arm); unregister?.() }
738
- }, 'dsh-kanban: инструмент доски')
742
+ }, 'dsh-kanban: board tool')
739
743
 
740
744
  /**
741
745
  * Состояние живого агента сессии либо `undefined`, если агента нет.
@@ -761,7 +765,7 @@ export function apply(ctx, config) {
761
765
  projectRoot: rootOf(getConfig()),
762
766
  }))
763
767
  },
764
- }), 'dsh-kanban: маршрут доски')
768
+ }), 'dsh-kanban: board route')
765
769
 
766
770
  // Снимок доски — ОДИН ресурс с двумя действиями, а не две ручки.
767
771
  //
@@ -800,7 +804,7 @@ export function apply(ctx, config) {
800
804
  staleDays: Number.isFinite(days) && days > 0 ? days : 14,
801
805
  }))
802
806
  },
803
- }), 'dsh-kanban: метрики доски')
807
+ }), 'dsh-kanban: board metrics')
804
808
 
805
809
  ctx.effect(() => ctx.webServer.register({
806
810
  kind: 'exact',
@@ -808,7 +812,7 @@ export function apply(ctx, config) {
808
812
  // Пустой логин — законный ответ: Gitea может быть не настроен или не
809
813
  // отвечать. Браузер по нему просто не рисует «Мои», а не показывает ноль.
810
814
  handler: async (req, res) => { reply(res, { login: await whoAmI() }) },
811
- }), 'dsh-kanban: кто мы для Gitea')
815
+ }), 'dsh-kanban: whoami')
812
816
 
813
817
  ctx.effect(() => ctx.webServer.register({
814
818
  kind: 'exact',
@@ -824,7 +828,7 @@ export function apply(ctx, config) {
824
828
  })
825
829
  res.end(csv)
826
830
  },
827
- }), 'dsh-kanban: экспорт в CSV')
831
+ }), 'dsh-kanban: csv export')
828
832
 
829
833
  ctx.effect(() => ctx.webServer.register({
830
834
  kind: 'exact',
@@ -832,7 +836,7 @@ export function apply(ctx, config) {
832
836
  handler: (req, res) => {
833
837
  eventHub.handleSseRequest(req, res)
834
838
  },
835
- }), 'dsh-kanban: поток живых событий SSE')
839
+ }), 'dsh-kanban: sse events stream')
836
840
 
837
841
  ctx.effect(() => ctx.webServer.register({
838
842
  kind: 'exact',
@@ -840,7 +844,7 @@ export function apply(ctx, config) {
840
844
  handler: (req, res) => {
841
845
  reply(res, { templates: TASK_TEMPLATES })
842
846
  },
843
- }), 'dsh-kanban: шаблоны задач')
847
+ }), 'dsh-kanban: task templates')
844
848
 
845
849
  ctx.effect(() => ctx.webServer.register({
846
850
  kind: 'exact',
@@ -856,7 +860,7 @@ export function apply(ctx, config) {
856
860
  writeJson(res, 400, { error: 'bad-request' })
857
861
  }
858
862
  },
859
- }), 'dsh-kanban: снимок доски')
863
+ }), 'dsh-kanban: board snapshot')
860
864
 
861
865
  ctx.effect(() => ctx.webServer.register({
862
866
  // Префикс регистрируется БЕЗ завершающего слэша: со слэшем сопоставление
@@ -942,7 +946,7 @@ export function apply(ctx, config) {
942
946
  syncPower()
943
947
  reply(res, started)
944
948
  } catch (error) {
945
- ctx.logger?.warn?.(`dsh-kanban: запуск задачи не удался: ${error?.message}`)
949
+ ctx.logger?.warn?.(`dsh-kanban: task launch failed: ${error?.message}`)
946
950
  // Отказ по уровню доступа называется своим именем: «не удалось» на
947
951
  // месте «сессия пошла бы с большими правами» — это молчание.
948
952
  writeJson(res, error?.status ?? 500, { error: error?.key ?? 'start-failed' })
@@ -1192,7 +1196,7 @@ export function apply(ctx, config) {
1192
1196
  writeJson(res, error?.status ?? 500, { error: 'bad-request' })
1193
1197
  }
1194
1198
  },
1195
- }), 'dsh-kanban: маршруты задачи')
1199
+ }), 'dsh-kanban: task routes')
1196
1200
 
1197
1201
  // Готовность Gitea проверяется на каждом запросе, а не при загрузке: адрес и
1198
1202
  // учётная запись правятся в карточке настроек на ходу, и маршруты обязаны
@@ -1204,7 +1208,7 @@ export function apply(ctx, config) {
1204
1208
  const url = urlOf(req)
1205
1209
  reply(res, await searchRepos({ gitea, query: url.searchParams.get('q') || '' }))
1206
1210
  },
1207
- }), 'dsh-kanban: поиск репозиториев')
1211
+ }), 'dsh-kanban: repo search')
1208
1212
 
1209
1213
  ctx.effect(() => ctx.webServer.register({
1210
1214
  kind: 'exact',
@@ -1219,7 +1223,7 @@ export function apply(ctx, config) {
1219
1223
  board: url.searchParams.get('board') || 'main',
1220
1224
  }))
1221
1225
  },
1222
- }), 'dsh-kanban: список issue для импорта')
1226
+ }), 'dsh-kanban: importable issues')
1223
1227
 
1224
1228
  ctx.effect(() => ctx.webServer.register({
1225
1229
  kind: 'exact',
@@ -1238,7 +1242,7 @@ export function apply(ctx, config) {
1238
1242
  writeJson(res, error?.status ?? 500, { error: 'bad-request' })
1239
1243
  }
1240
1244
  },
1241
- }), 'dsh-kanban: импорт issue')
1245
+ }), 'dsh-kanban: issue import')
1242
1246
 
1243
1247
  // Провайдеры отдаются сразу, модели — только для запрошенного провайдера:
1244
1248
  // перечень моделей провайдер отдаёт по сети, и опрашивать все разом ради
@@ -1269,7 +1273,7 @@ export function apply(ctx, config) {
1269
1273
  writeJson(res, 200, { current, providers, models: [] })
1270
1274
  }
1271
1275
  },
1272
- }), 'dsh-kanban: провайдеры и модели')
1276
+ }), 'dsh-kanban: providers and models')
1273
1277
 
1274
1278
  ctx.effect(() => ctx.webServer.register({
1275
1279
  kind: 'prefix',
@@ -1279,5 +1283,12 @@ export function apply(ctx, config) {
1279
1283
  if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
1280
1284
  reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]) }))
1281
1285
  },
1282
- }), '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
+
1283
1294
  }
package/lib/power.js CHANGED
@@ -134,7 +134,7 @@ export class PowerInhibitor {
134
134
  this.child.stdin.end()
135
135
  }
136
136
  this.child.kill()
137
- } catch {}
137
+ } catch { /* process kill best-effort */ }
138
138
  this.child = null
139
139
  }
140
140
  this.phase = 'disabled'
@@ -1,9 +1,9 @@
1
1
  // dsh-kanban: фиксация и безопасная передача среза прогресса задачи (PROGRESSDUMP).
2
2
  // Парсит блок <<<PROGRESSDUMP ... >>>PROGRESSDUMP, маскирует секреты и генерирует вводный бриф.
3
3
 
4
- export const BEGIN_MARKER = '<<<PROGRESSDUMP'
5
- export const END_MARKER = '>>>PROGRESSDUMP'
6
- export const FIELD_BYTE_LIMIT = 8 * 1024
4
+ const BEGIN_MARKER = '<<<PROGRESSDUMP'
5
+ const END_MARKER = '>>>PROGRESSDUMP'
6
+ const FIELD_BYTE_LIMIT = 8 * 1024
7
7
  export const REDACTED = '[REDACTED]'
8
8
 
9
9
  const SENSITIVE_PATTERNS = [
package/lib/routes.js CHANGED
@@ -19,12 +19,54 @@ import { computeNextRun } from './cron.js'
19
19
  /** Больше этого тело запроса не читаем: без предела один запрос кладёт хост. */
20
20
  export const MAX_BODY_BYTES = 256 * 1024
21
21
 
22
+ function isLoopbackAddress(addr) {
23
+ if (!addr) return true
24
+ return addr === '127.0.0.1' ||
25
+ addr === '::1' ||
26
+ addr === '::ffff:127.0.0.1' ||
27
+ addr === 'localhost'
28
+ }
29
+
22
30
  /**
23
31
  * Запрос с чужого сайта. Изменяющие маршруты правят локальное состояние, и
24
- * пускать в них кросс-сайтовый запрос нельзя.
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).
25
37
  */
26
38
  export function isTrustedRequest(req) {
27
- return req?.headers?.['sec-fetch-site'] !== 'cross-site'
39
+ if (!req || !req.headers) return false
40
+
41
+ // 1. Проверка sec-fetch-site (отсутствие или cross-site запрещены)
42
+ const site = req.headers['sec-fetch-site']
43
+ if (!site || site === 'cross-site') {
44
+ return false
45
+ }
46
+ if (site !== 'same-origin' && site !== 'same-site' && site !== 'none') {
47
+ return false
48
+ }
49
+
50
+ // 2. Сверка origin с host
51
+ const origin = req.headers.origin
52
+ if (origin !== undefined) {
53
+ if (origin === 'null') return false
54
+ try {
55
+ const parsed = new URL(origin)
56
+ const host = req.headers.host || ''
57
+ if (parsed.host && parsed.host !== host) return false
58
+ } catch {
59
+ return false
60
+ }
61
+ }
62
+
63
+ // 3. Проверка loopback remoteAddress
64
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
65
+ if (remote && !isLoopbackAddress(remote)) {
66
+ return false
67
+ }
68
+
69
+ return true
28
70
  }
29
71
 
30
72
  /** Разобрать `/dsh-kanban/task/<id>[/<action>]`; иначе undefined. */
@@ -232,7 +274,7 @@ export function confirmPermission({ store, id }) {
232
274
  }
233
275
 
234
276
  /** Известные приоритеты. Пустой — «без приоритета». */
235
- export const PRIORITIES = ['', 'high', 'medium', 'low']
277
+ const PRIORITIES = ['', 'high', 'medium', 'low']
236
278
 
237
279
  /**
238
280
  * Дописать заметку в тело задачи.
@@ -392,7 +434,7 @@ export function importBoard({ store, input }) {
392
434
  if (typeof store.backupDatabase === 'function') {
393
435
  try {
394
436
  backupMeta = store.backupDatabase()
395
- } catch {}
437
+ } catch { /* backup best effort */ }
396
438
  }
397
439
  if (input === null || typeof input !== 'object') return { error: 'bad-import', status: 400 }
398
440
  if (input.version !== 1) return { error: 'bad-import-version', status: 400 }
@@ -684,7 +726,7 @@ export async function handleTriageTask({ store, gitea, task, input }) {
684
726
  issueNumber = giteaIssue.number
685
727
  issueUrl = giteaIssue.html_url || null
686
728
  }
687
- } catch {}
729
+ } catch { /* ignore gitea subtask creation failure */ }
688
730
  }
689
731
 
690
732
  const sub = store.createTask({
@@ -760,7 +802,7 @@ export async function handleCreateSubtask({ store, gitea, task, input }) {
760
802
  issueNumber = giteaIssue.number
761
803
  issueUrl = giteaIssue.html_url || null
762
804
  }
763
- } catch {}
805
+ } catch { /* ignore gitea subtask creation failure */ }
764
806
  }
765
807
 
766
808
  store.updateTask(task.id, { isEpic: true })
package/lib/store.js CHANGED
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS tasks (
37
37
  repo TEXT,
38
38
  issueNumber INTEGER,
39
39
  issueUrl TEXT,
40
+ externalRef TEXT,
40
41
  labels TEXT NOT NULL DEFAULT '[]',
41
42
  branch TEXT,
42
43
  worktree TEXT,
@@ -97,7 +98,7 @@ CREATE TABLE IF NOT EXISTS dismissed (
97
98
 
98
99
  /** Поля задачи, которые разрешено править через updateTask. */
99
100
  const WRITABLE = [
100
- 'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
101
+ 'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'externalRef', 'labels',
101
102
  'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
102
103
  'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
103
104
  'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
@@ -168,6 +169,8 @@ export function openStore({ dir }) {
168
169
  const db = new DatabaseSync(join(dir, 'kanban.db'))
169
170
  db.exec('PRAGMA journal_mode = WAL')
170
171
  db.exec('PRAGMA foreign_keys = ON')
172
+ db.exec('PRAGMA busy_timeout = 5000')
173
+ db.exec('PRAGMA busy_timeout = 5000')
171
174
  db.exec(SCHEMA)
172
175
  // База могла быть создана прежней версией. Добавляем недостающие столбцы
173
176
  // поштучно: ALTER на существующий столбец бросает, и это штатный ответ
@@ -201,6 +204,7 @@ export function openStore({ dir }) {
201
204
  ['rejectedAt', "INTEGER NOT NULL DEFAULT 0"],
202
205
  ['parentId', "TEXT NOT NULL DEFAULT ''"],
203
206
  ['isEpic', "INTEGER NOT NULL DEFAULT 0"],
207
+ ['externalRef', "TEXT"],
204
208
  ]) {
205
209
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
206
210
  }
@@ -212,11 +216,12 @@ export function openStore({ dir }) {
212
216
  // Индексы для ускорения выборок активных задач, поиска по Gitea issue и cron-задач
213
217
  db.exec('CREATE INDEX IF NOT EXISTS tasks_board_active ON tasks(board, archivedAt, col, position)')
214
218
  db.exec('CREATE INDEX IF NOT EXISTS tasks_gitea_issue ON tasks(owner, repo, issueNumber)')
219
+ db.exec('CREATE INDEX IF NOT EXISTS tasks_external_ref ON tasks(externalRef)')
215
220
  db.exec('CREATE INDEX IF NOT EXISTS tasks_cron_next ON tasks(archivedAt, nextRunAt)')
216
221
  db.exec('CREATE INDEX IF NOT EXISTS tasks_queued ON tasks(archivedAt, queuedAt)')
217
222
  db.exec('CREATE INDEX IF NOT EXISTS tasks_parent_id ON tasks(parentId)')
218
223
  db.exec('PRAGMA synchronous = NORMAL')
219
- try { db.exec('PRAGMA optimize') } catch {}
224
+ try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
220
225
 
221
226
  const q = {
222
227
  boards: db.prepare('SELECT * FROM boards ORDER BY position'),
@@ -233,6 +238,7 @@ export function openStore({ dir }) {
233
238
  dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, at) VALUES (?, ?, ?, ?)'),
234
239
  isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
235
240
  byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
241
+ byExternalRef: db.prepare('SELECT * FROM tasks WHERE externalRef = ? ORDER BY createdAt ASC LIMIT 1'),
236
242
  tasksBySession: db.prepare('SELECT * FROM tasks WHERE sessionId = ? LIMIT 1'),
237
243
  lastInColumn: db.prepare('SELECT position FROM tasks WHERE board = ? AND col = ? ORDER BY position DESC LIMIT 1'),
238
244
  deleteTask: db.prepare('DELETE FROM tasks WHERE id = ?'),
@@ -298,10 +304,10 @@ export function openStore({ dir }) {
298
304
  .sort((a, b) => b.time - a.time)
299
305
  if (files.length > 10) {
300
306
  for (const old of files.slice(10)) {
301
- try { unlinkSync(old.path) } catch {}
307
+ try { unlinkSync(old.path) } catch { /* unlink old backup best-effort */ }
302
308
  }
303
309
  }
304
- } catch {}
310
+ } catch { /* backup rotation best-effort */ }
305
311
  return { backup: backupName, path: dest }
306
312
  },
307
313
 
@@ -373,16 +379,16 @@ export function openStore({ dir }) {
373
379
  const id = typeof input.id === 'string' && input.id !== '' ? input.id : randomUUID()
374
380
  const position = positionFor(board, column, input.beforeId, input.afterId)
375
381
  db.prepare(`INSERT INTO tasks
376
- (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
382
+ (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl, externalRef,
377
383
  labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
378
384
  labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
379
385
  cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
380
386
  checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic)
381
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
387
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
382
388
  id, board, column, position,
383
389
  input.title ?? '', input.body ?? '',
384
390
  input.owner ?? null, input.repo ?? null,
385
- input.issueNumber ?? null, input.issueUrl ?? null,
391
+ input.issueNumber ?? null, input.issueUrl ?? null, input.externalRef ?? null,
386
392
  JSON.stringify(input.labels ?? []),
387
393
  input.branch ?? null, input.worktree ?? null, input.workspaceId ?? '',
388
394
  input.sessionId ?? null, input.model ?? null, input.provider ?? null,
@@ -569,6 +575,12 @@ export function openStore({ dir }) {
569
575
  return rowToTask(q.byIssue.get(owner, repo, issueNumber))
570
576
  },
571
577
 
578
+ findTaskByExternalRef(externalRef) {
579
+ const key = typeof externalRef === 'string' ? externalRef.trim() : ''
580
+ if (!key) return undefined
581
+ return rowToTask(q.byExternalRef.get(key))
582
+ },
583
+
572
584
  listSubtasks(parentId) {
573
585
  if (!parentId) return []
574
586
  return q.subtasksOf.all(parentId).map(rowToTask)