@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/index.js CHANGED
@@ -1,14 +1,13 @@
1
1
  import { registerPluginUpdater } from './updater.js'
2
- // dsh-kanban — доска задач для DeepSeek Harness.
2
+ // dsh-kanban — task board for DeepSeek Harness.
3
3
  //
4
- // Плагин состоит из двух половин: этот модуль — серверная, `lib/client.js` —
5
- // браузерная. Имя пакета `@goodandready/dsh-kanban` обязано совпадать в трёх
6
- // местах: `package.json`, `cordis.patch.yml` и `load({ id })` в client.js.
7
- // Расхождение НЕ даёт ошибки в журнале — интерфейс просто не появляется.
4
+ // Plugin consists of two halves: this module is the backend server, and `lib/client.js` is the
5
+ // browser bundle. The package name `@goodandready/dsh-kanban` must match in three places:
6
+ // `package.json`, `cordis.patch.yml`, and `load({ id })` in client.js.
8
7
  //
9
- // Чистая логика вынесена в модули без зависимостей (`config.js`, `order.js`,
10
- // `routes.js`) и проверяется тестами без харнесса. Здесь остаётся обвязка
11
- // cordis: настройки, хранилище и маршруты.
8
+ // Pure domain logic is kept in standalone modules (`config.js`, `order.js`, `routes.js`)
9
+ // and tested without harness runtime. This module handles cordis integration:
10
+ // configuration, database storage, and HTTP endpoints.
12
11
 
13
12
  import {
14
13
  MAX_BODY_BYTES,
@@ -75,7 +74,7 @@ import {
75
74
  boardDecomposeDefinition,
76
75
  boardChecklistItemDefinition,
77
76
  } from './board-tool.js'
78
- import { verifySignature, parseEvent } from './webhook.js'
77
+ import { verifySignature, parseEvent, detectWebhookProvider } from './webhook.js'
79
78
  import { planOutbound, planAssign, createOutbox } from './outbound.js'
80
79
  import { computeNextRun } from './cron.js'
81
80
  import { PowerInhibitor } from './power.js'
@@ -102,35 +101,27 @@ import { TASK_TEMPLATES, getTemplateById } from './templates.js'
102
101
 
103
102
  export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
104
103
 
105
- /** Стабильное имя плагина cordis (строка в патче сборки). */
104
+ /** Stable cordis plugin name (patch identifier). */
106
105
  export const name = '@goodandready/dsh-kanban'
107
106
 
108
107
  /**
109
- * Службы добавляются там, где впервые нужны: `gitea` — вместе с импортом
110
- * задач, `agents`/`sessions` — вместе с запуском работы. Объявленная, но
111
- * отсутствующая служба не даёт плагину загрузиться вовсе.
108
+ * Services are injected where needed. Declaring an unavailable service prevents
109
+ * the plugin from loading entirely.
112
110
  */
113
111
  export const inject = ['settings', 'webServer', 'agents', 'agentDefaultModel', 'credentials', 'llm', 'tools']
114
112
 
115
- /** Пространство настроек; то же имя продублировано в браузерной половине. */
116
- // Пространство настроек — просто строка.
117
- //
118
- // В 0.1.1 ядро отдавало помощника `settingsNamespace`, который проверял имя по
119
- // образцу и возвращал ту же строку с типом-меткой. В 0.1.2-alpha.2 помощника
120
- // убрали, и импорт его роняет ЗАГРУЗКУ ВСЕГО ПРОФИЛЯ, а не один плагин.
121
- // Проверять здесь нечего: имя — постоянная этого файла, а не пользовательский
122
- // ввод. На обеих версиях ядра значение одно и то же.
113
+ /** Settings namespace string matching client bundle definition. */
114
+ // Constant string defined in this file; identical across core versions.
123
115
  export const SETTINGS_NAMESPACE = 'dsh-kanban'
124
116
 
125
117
  /**
126
- * Схема собирается из значений по умолчанию, чтобы список полей жил в одном
127
- * месте. Только скаляры: клиентский API настроек пишет ТОЛЬКО скалярные поля,
128
- * и словарь или массив здесь сделал бы карточку нередактируемой.
118
+ * Schema constructed from default values so fields reside in one place.
119
+ * Scalar values only: client config API only writes scalar fields;
120
+ * dictionaries or arrays here would make the settings card uneditable.
129
121
  */
130
122
  export const Config = z.object(Object.fromEntries(
131
123
  Object.entries(CONFIG_DEFAULTS).map(([field, fallback]) => {
132
- // Узел схемы выбирается по типу умолчания. Пропустить здесь булево значит
133
- // объявить его строкой — и плагин не загрузится вовсе с «invalid config».
124
+ // Node type derived from default value type.
134
125
  let node = typeof fallback === 'number' ? z.number()
135
126
  : typeof fallback === 'boolean' ? z.boolean()
136
127
  : z.string()
@@ -141,7 +132,7 @@ export const Config = z.object(Object.fromEntries(
141
132
  }),
142
133
  ))
143
134
 
144
- /** Каталог хранилища. Абсолютных путей в коде нет — только переменная и дом. */
135
+ /** Storage directory path resolved from environment variable or user home directory. */
145
136
  export function storeDir() {
146
137
  return process.env.DSH_KANBAN_DIR || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'kanban')
147
138
  }
@@ -153,7 +144,7 @@ function writeJson(res, code, body) {
153
144
  } catch { /* socket closed */ }
154
145
  }
155
146
 
156
- /** Ответ обработчика: либо объект с `error` и `status`, либо полезная нагрузка. */
147
+ /** Handler response helper: either an error object or success payload. */
157
148
  function reply(res, out) {
158
149
  if (out && typeof out === 'object' && typeof out.error === 'string') {
159
150
  writeJson(res, out.status ?? 400, { error: out.error })
@@ -185,11 +176,9 @@ function readBody(req) {
185
176
  }
186
177
 
187
178
  /**
188
- * Сырое тело запроса.
189
- *
190
- * Подпись вебхука считается по БАЙТАМ доставки. Разобрать JSON и собрать его
191
- * обратно — значит получить другую последовательность байт и не сойтись в
192
- * подписи на пустом месте.
179
+ * Read raw request body bytes.
180
+ * Webhook signatures are computed over delivery byte buffers; parsing and re-serializing
181
+ * could alter byte sequences and fail signature verification.
193
182
  */
194
183
  function readRaw(req) {
195
184
  return new Promise((resolve, reject) => {
@@ -217,9 +206,8 @@ export function apply(ctx, config) {
217
206
  let effective = withDefaults(config)
218
207
  const getConfig = () => effective
219
208
 
220
- // Часть настроек управляет живыми подписками: промежутком сверки и наличием
221
- // инструмента у агента. Прочитать их один раз при загрузке — значит сделать
222
- // переключатель в карточке настроек безответным до перезапуска харнесса.
209
+ // Settings control live subscriptions: sync intervals and agent tools.
210
+ // Dynamic watcher re-evaluates configurations on change without requiring restart.
223
211
  const rearmers = new Set()
224
212
  const rearm = () => {
225
213
  for (const again of rearmers) {
@@ -234,9 +222,8 @@ export function apply(ctx, config) {
234
222
  ctx.effect(() => scope.watch(() => { effective = read(); rearm() }), 'dsh-kanban: settings watcher')
235
223
  })
236
224
 
237
- // Клиент Gitea свой: токен всё равно живёт в службе учётных данных ядра, а
238
- // шесть вызовов REST дешевле зависимости от чужого выпуска. Значение токена
239
- // наружу не отдаётся — в настройках только ИМЯ учётной записи.
225
+ // Dedicated Gitea client resolves token via core credentials service.
226
+ // Secret token values are never exposed externally; only the credential reference name is stored.
240
227
  const resolveSecret = async (name) => {
241
228
  if (typeof name !== 'string' || name.trim() === '') return ''
242
229
  try {
@@ -263,8 +250,7 @@ export function apply(ctx, config) {
263
250
  getConfig,
264
251
  })
265
252
 
266
- // Хранилище открывается один раз и закрывается уборщиком того же эффекта:
267
- // выгрузка плагина обязана отпустить файл базы.
253
+ // Database store opens once and is closed on effect disposal.
268
254
  let store
269
255
  const taskService = createKanbanTaskService({ getStore: () => store })
270
256
  if (typeof ctx.provide === 'function') ctx.provide('dshKanban', taskService)
@@ -273,7 +259,7 @@ export function apply(ctx, config) {
273
259
  return () => { try { store.close() } catch { /* already closed */ } }
274
260
  }, 'dsh-kanban: task store')
275
261
 
276
- // Кроссплатформенный ингибитор сна системы во время работы задач
262
+ // Cross-platform system sleep inhibitor while tasks are actively running
277
263
  const powerInhibitor = new PowerInhibitor({
278
264
  platform: process.platform,
279
265
  pid: process.pid,
@@ -312,7 +298,7 @@ export function apply(ctx, config) {
312
298
  return () => { powerInhibitor.destroy() }
313
299
  }, 'dsh-kanban: power inhibitor')
314
300
 
315
- // Инъекция секции доски в системный промпт агента
301
+ // Inject board instructions into agent system prompt
316
302
  let disposePromptSection
317
303
  const syncPromptSection = () => {
318
304
  if (disposePromptSection) {
@@ -339,10 +325,10 @@ export function apply(ctx, config) {
339
325
  return () => { if (disposePromptSection) { try { disposePromptSection() } catch { /* cleanup best effort */ } } }
340
326
  }, 'dsh-kanban: system prompt section')
341
327
 
342
- // Признак ожидания, исходы сессий и PROGRESSDUMP по событиям сессии.
328
+ // Track waiting flags, session outcomes, and progress dump from session events.
343
329
  ctx.effect(() => ctx.on('session/event', (session, event) => {
344
330
  try {
345
- // Автосинхронизация сессий чата DSH в карточки канбана (#225)
331
+ // Auto-sync DSH chat sessions into kanban cards (#225)
346
332
  const sessionIdStr = String(session?.id ?? '')
347
333
  if (
348
334
  getConfig().autoSyncSessions === true &&
@@ -395,7 +381,7 @@ export function apply(ctx, config) {
395
381
  error: event?.error,
396
382
  })
397
383
  syncPower()
398
- // Если сессия завершила шаг или упала — пробуем продвинуть очередь задач (#214)
384
+ // Dispatch next queued task when a session completes or fails (#214)
399
385
  if (event?.type === 'turn/end' || event?.type === 'turn/completed' || event?.type === 'session/error' || event?.type === 'turn/error') {
400
386
  dispatchNextQueuedTask({
401
387
  agents: ctx.agents,
@@ -414,7 +400,7 @@ export function apply(ctx, config) {
414
400
  }
415
401
  }), 'dsh-kanban: session events')
416
402
 
417
- // Фоновый шедулер cron для задач
403
+ // Background cron scheduler for scheduled tasks
418
404
  ctx.effect(() => {
419
405
  let cronTimer
420
406
  const armCron = () => {
@@ -482,12 +468,8 @@ export function apply(ctx, config) {
482
468
  return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
483
469
  }, 'dsh-kanban: cron scheduler')
484
470
 
485
- // Периодическая сверка с Gitea. Ноль в настройках отключает её целиком, а
486
- // перекрытие запусков не допускается: медленный инстанс иначе копил бы
487
- // очередь сверок вместо одной идущей.
488
- // Очередь отправки в Gitea. Перенос карточки не ждёт сети: доска обязана
489
- // работать и при недоступном Gitea, а неудачная отправка повторяется и, если
490
- // так и не удалась, попадает в журнал задачи.
471
+ // Periodic Gitea reconciliation. 0 disables it; overlapping executions are prevented.
472
+ // Outbound push queue to Gitea. Card moves do not block on network; failures retry in background.
491
473
  const outbox = createOutbox({ gitea: git, store, logger: ctx.logger })
492
474
 
493
475
  const queueOutbound = (taskId, column) => {
@@ -499,11 +481,8 @@ export function apply(ctx, config) {
499
481
  }
500
482
 
501
483
  /**
502
- * Владелец для коротких имён в списке отслеживаемых репозиториев.
503
- *
504
- * Берётся из задач, уже лежащих на доске: другого источника у плагина нет,
505
- * а спрашивать владельца отдельной настройкой значит просить дважды одно и
506
- * то же.
484
+ * Default repository owner for short repository names.
485
+ * Inferred from existing board tasks when not explicitly configured.
507
486
  */
508
487
  let discoveredOwner
509
488
  const defaultOwner = async () => {
@@ -513,15 +492,13 @@ export function apply(ctx, config) {
513
492
  if (typeof task.owner === 'string' && task.owner !== '') return task.owner
514
493
  }
515
494
  if (discoveredOwner !== undefined) return discoveredOwner
516
- // Спрашиваем у Gitea, и только когда организация ровно одна: при
517
- // нескольких гадание молча смотрело бы не туда, а это хуже отказа.
495
+ // Fall back to Gitea organization if exactly one is accessible to the token.
518
496
  const orgs = await gitea.listOrgs()
519
497
  if (orgs.length === 1) { discoveredOwner = orgs[0]; return discoveredOwner }
520
498
  return undefined
521
499
  }
522
500
 
523
- // Состояние сверки видно человеку в шапке доски: доска, переставшая
524
- // обновляться, не имеет права молчать об этом.
501
+ // Sync telemetry state visible in board header.
525
502
  const syncState = createSyncState()
526
503
 
527
504
  let syncing = false
@@ -530,9 +507,8 @@ export function apply(ctx, config) {
530
507
  syncing = true
531
508
  syncState.started()
532
509
  try {
533
- // Порядок важен. Сперва забираем новое: свежая задача должна попасть на
534
- // доску и тут же быть сверена, а не ждать следующего прохода. Архивация
535
- // последней — она смотрит на итог, а не на исходное.
510
+ // Order of operations: intake new issues first, reconcile active tasks, archive overdue.
511
+ // Inspect final outcome rather than initial state.
536
512
  const mode = String(getConfig()?.gitProvider || 'auto').toLowerCase()
537
513
  const providersToSync = []
538
514
  if (mode === 'github') {
@@ -562,18 +538,15 @@ export function apply(ctx, config) {
562
538
  }
563
539
  const out = await syncAll({ gitea: git, store, logger: ctx.logger })
564
540
  const tidied = archiveOverdue({ store, config: getConfig() })
565
- // Отложенные отправки уходят той же волной: отдельный таймер ради них
566
- // означал бы второй источник обращений к Gitea без всякой пользы.
541
+ // Outbox flushes run in the same cycle to minimize redundant Gitea API calls.
567
542
  const pushed = await outbox.flush()
568
- // Беда подхвата — такая же слепота доски, как беда сверки: задачи не
569
- // приезжают, а человеку об этом никто не говорит.
543
+ // Intake errors are reported in sync telemetry to keep failures visible.
570
544
  syncState.finished(out.problem === undefined && taken.problem !== undefined
571
545
  ? { ...out, problem: taken.problem }
572
546
  : out)
573
547
  return { ...out, pushed, taken, tidied }
574
548
  } catch (error) {
575
- // Сверка не начиналась вовсе — тоже беда, и своя: так выглядит
576
- // неразрешённый токен или отсутствующий клиент Gitea.
549
+ // Sync failure to start represents missing client or unconfigured credentials.
577
550
  syncState.failedToStart('sync', error?.message)
578
551
  throw error
579
552
  } finally {
@@ -620,17 +593,14 @@ export function apply(ctx, config) {
620
593
  ctx.effect(() => ctx.webServer.register({
621
594
  kind: 'exact',
622
595
  path: '/dsh-kanban/presets',
623
- // Обе службы необязательны: развёртывание может не собирать ни профили,
624
- // ни уровни доступа. Пустой список — не поломка, а «выбирать не из чего»,
625
- // и браузер просто не рисует поле.
596
+ // Both services are optional in minimal environments. Empty lists render gracefully.
626
597
  handler: async (req, res) => {
627
598
  const presets = ctx.get('agentPresets')
628
599
  const permissions = ctx.get('permissionPresets')
629
600
  let agentPresets = []
630
601
  try {
631
602
  const rows = presets === undefined ? [] : await presets.list()
632
- // Сломанный профиль остаётся в списке ядра, но выбрать его нельзя:
633
- // сессия на нём не соберётся, а отказ прилетит уже после запуска.
603
+ // Filter out invalid presets that would fail session creation.
634
604
  agentPresets = rows.filter((row) => row?.broken === undefined).map((row) => ({
635
605
  id: row.id, name: row.name || row.id, description: row.description,
636
606
  }))
@@ -659,7 +629,10 @@ export function apply(ctx, config) {
659
629
  ctx.effect(() => ctx.webServer.register({
660
630
  kind: 'exact',
661
631
  path: '/dsh-kanban/sessions',
662
- handler: (req, res) => { reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) }) },
632
+ handler: (req, res) => {
633
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
634
+ reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) })
635
+ },
663
636
  }), 'dsh-kanban: live sessions')
664
637
 
665
638
  ctx.effect(() => ctx.webServer.register({
@@ -697,7 +670,10 @@ export function apply(ctx, config) {
697
670
  ctx.effect(() => ctx.webServer.register({
698
671
  kind: 'exact',
699
672
  path: '/dsh-kanban/archive',
700
- handler: (req, res) => { reply(res, listArchive({ store })) },
673
+ handler: (req, res) => {
674
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
675
+ reply(res, listArchive({ store }))
676
+ },
701
677
  }), 'dsh-kanban: archive')
702
678
 
703
679
  ctx.effect(() => ctx.webServer.register({
@@ -721,7 +697,7 @@ export function apply(ctx, config) {
721
697
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
722
698
  try {
723
699
  const raw = await readRaw(req)
724
- const isGithub = Boolean(req.headers['x-github-event'] || req.headers['x-hub-signature-256'])
700
+ const isGithub = detectWebhookProvider(req.headers) === 'github'
725
701
  const secretRef = isGithub
726
702
  ? (getConfig().githubWebhookSecretRef || getConfig().webhookSecretRef)
727
703
  : getConfig().webhookSecretRef
@@ -761,9 +737,7 @@ export function apply(ctx, config) {
761
737
  },
762
738
  }), 'dsh-kanban: gitea webhook')
763
739
 
764
- // Инструмент для агента. По умолчанию ВЫКЛЮЧЕН: скилл воркфлоу пока
765
- // запрещает этому CLI трогать канбан, и включить инструмент раньше правки
766
- // скилла — значит толкать агента на нарушение.
740
+ // Agent tool definition. Default disabled; workflow skills explicitly enable it.
767
741
  ctx.effect(() => {
768
742
  let unregister
769
743
  const arm = () => {
@@ -773,8 +747,7 @@ export function apply(ctx, config) {
773
747
  unregister = registerBoardTool()
774
748
  }
775
749
 
776
- // Один выключатель на оба инструмента доски: разделять их значило бы
777
- // множить настройки ради разницы, которой человек не почувствует.
750
+ // Single setting switch controls both board tools.
778
751
  const registerBoardTool = () => {
779
752
  const off = [
780
753
  ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig }))),
@@ -795,8 +768,7 @@ export function apply(ctx, config) {
795
768
  }, 'dsh-kanban: board tool')
796
769
 
797
770
  /**
798
- * Состояние живого агента сессии либо `undefined`, если агента нет.
799
- * Отказ реестра на незнакомый идентификатор ловит сборка доски.
771
+ * Return active agent state or undefined if session is inactive.
800
772
  */
801
773
  const liveOf = (sessionId) => ctx.agents.get(sessionId)?.status
802
774
 
@@ -804,6 +776,7 @@ export function apply(ctx, config) {
804
776
  kind: 'exact',
805
777
  path: '/dsh-kanban/board',
806
778
  handler: (req, res) => {
779
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
807
780
  const url = urlOf(req)
808
781
  reply(res, buildBoard({
809
782
  store,
@@ -812,27 +785,15 @@ export function apply(ctx, config) {
812
785
  repo: url.searchParams.get('repo') || undefined,
813
786
  liveOf,
814
787
  sync: syncState.snapshot(),
815
- // Фактический корень проектов едет с доской: браузер показывает его в
816
- // окне запуска, когда человек не задал корень настройкой, — иначе
817
- // сессия молча поднимается в рабочей папке харнесса.
788
+ // Resolved project root passed with board payload for session launch modal.
818
789
  projectRoot: rootOf(getConfig()),
819
790
  }))
820
791
  },
821
792
  }), 'dsh-kanban: board route')
822
793
 
823
- // Снимок доски — ОДИН ресурс с двумя действиями, а не две ручки.
824
- //
825
- // Прежде выгрузка звалась `/export`, а загрузка занимала `/import` — тот
826
- // самый путь, на котором с первого дня живёт импорт issue из Gitea. Два
827
- // обработчика на одном пути означают, что один из них не отвечает вовсе, и
828
- // какой именно — решает порядок регистрации, а не мы.
794
+ // Snapshot endpoint handles both export and import.
829
795
  /**
830
- * Кто мы для Gitea, с памятью на время работы.
831
- *
832
- * Логин нужен и «взять себе», и запуску работы. Спрашивать его у Gitea
833
- * каждый раз значит добавить в путь запуска ещё одну сеть, которая может не
834
- * ответить; поэтому спрашиваем один раз и помним. Не ответила — работаем без
835
- * назначения, а не отказываем в запуске.
796
+ * Cached identity for Gitea client to avoid redundant API lookups on task claim.
836
797
  */
837
798
  let knownLogin
838
799
  const whoAmI = async () => {
@@ -849,6 +810,7 @@ export function apply(ctx, config) {
849
810
  kind: 'exact',
850
811
  path: '/dsh-kanban/metrics',
851
812
  handler: (req, res) => {
813
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
852
814
  const url = urlOf(req)
853
815
  const days = Number(url.searchParams.get('staleDays'))
854
816
  reply(res, boardMetrics({
@@ -862,15 +824,18 @@ export function apply(ctx, config) {
862
824
  ctx.effect(() => ctx.webServer.register({
863
825
  kind: 'exact',
864
826
  path: '/dsh-kanban/whoami',
865
- // Пустой логин — законный ответ: Gitea может быть не настроен или не
866
- // отвечать. Браузер по нему просто не рисует «Мои», а не показывает ноль.
867
- handler: async (req, res) => { reply(res, { login: await whoAmI() }) },
827
+ // Empty login indicates unconfigured or unreachable Gitea; client omits "Mine" filter.
828
+ handler: async (req, res) => {
829
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
830
+ reply(res, { login: await whoAmI() })
831
+ },
868
832
  }), 'dsh-kanban: whoami')
869
833
 
870
834
  ctx.effect(() => ctx.webServer.register({
871
835
  kind: 'exact',
872
836
  path: '/dsh-kanban/export.csv',
873
837
  handler: async (req, res) => {
838
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
874
839
  const u = urlOf(req)
875
840
  const board = u.searchParams.get('board') || 'main'
876
841
  const csv = exportTasksCsv({ store, board })
@@ -891,7 +856,7 @@ export function apply(ctx, config) {
891
856
  },
892
857
  }), 'dsh-kanban: sse events stream')
893
858
 
894
- // Очистка SSE-клиентов и heartbeat таймера при выгрузке плагина (#282)
859
+ // Clean up SSE client connections and heartbeat timer on plugin disposal (#282)
895
860
  if (typeof ctx.on === 'function') {
896
861
  ctx.on('dispose', () => {
897
862
  eventHub.closeAll()
@@ -902,6 +867,7 @@ export function apply(ctx, config) {
902
867
  kind: 'exact',
903
868
  path: '/dsh-kanban/templates',
904
869
  handler: (req, res) => {
870
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
905
871
  const u = new URL(req.url, 'http://localhost')
906
872
  const tid = u.searchParams.get('id')
907
873
  if (tid) {
@@ -931,14 +897,13 @@ export function apply(ctx, config) {
931
897
  }), 'dsh-kanban: board snapshot')
932
898
 
933
899
  ctx.effect(() => ctx.webServer.register({
934
- // Префикс регистрируется БЕЗ завершающего слэша: со слэшем сопоставление
935
- // сводится к равенству пути, и всё, что глубже, уходит в статику.
900
+ // Prefix registered without trailing slash for sub-path routing.
936
901
  kind: 'prefix',
937
902
  path: '/dsh-kanban/task',
938
903
  handler: async (req, res) => {
939
904
  const pathname = urlOf(req).pathname
940
905
 
941
- // Сам путь без идентификатора — создание своей задачи.
906
+ // Path without ID handles local task creation.
942
907
  if (pathname === '/dsh-kanban/task' || pathname === '/dsh-kanban/task/') {
943
908
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
944
909
  if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
@@ -957,8 +922,7 @@ export function apply(ctx, config) {
957
922
  if (req.method === 'GET' && action === 'log') { reply(res, taskLog({ store, id })); return }
958
923
  if (req.method === 'GET' && action === 'body') { reply(res, taskBody({ store, id })); return }
959
924
 
960
- // Заготовку сообщения отдаём отсюда, а не собираем второй раз в браузере:
961
- // иначе шаблон окажется в двух местах и однажды разойдётся.
925
+ // Generate start prompt on server for template consistency.
962
926
  if (req.method === 'GET' && action === 'message') {
963
927
  const task = store.getTask(id)
964
928
  if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
@@ -967,8 +931,7 @@ export function apply(ctx, config) {
967
931
  }
968
932
  if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
969
933
  if (req.method === 'DELETE' && action === undefined) { reply(res, deleteTask({ store, id })); return }
970
- // Правка полей карточки: title, body, метки, приоритет. Приоритет —
971
- // локальное свойство доски, в Gitea не пишется (updateTask это знает).
934
+ // Card fields update: title, body, labels, priority. Priority is board-local.
972
935
  if (req.method === 'POST' && action === 'update') {
973
936
  try {
974
937
  const input = await readBody(req)
@@ -1044,8 +1007,7 @@ export function apply(ctx, config) {
1044
1007
  config: getConfig(),
1045
1008
  provider: picked.provider,
1046
1009
  model: picked.model,
1047
- // Идентификатор мнётся ЛЕНИВО: при продолжении новая сессия не
1048
- // поднимается вовсе, и заранее занятое имя осталось бы висеть.
1010
+ // Mint session ID lazily to avoid dangling IDs on resume.
1049
1011
  mintSessionId: () => SessionId(`kanban-${id}-${randomUUID()}`),
1050
1012
  createMessage: createUserMessage,
1051
1013
  logger: ctx.logger,
@@ -1060,8 +1022,7 @@ export function apply(ctx, config) {
1060
1022
  reply(res, started)
1061
1023
  } catch (error) {
1062
1024
  ctx.logger?.warn?.(`dsh-kanban: task launch failed: ${error?.message}`)
1063
- // Отказ по уровню доступа называется своим именем: «не удалось» на
1064
- // месте «сессия пошла бы с большими правами» — это молчание.
1025
+ // Explicit permission errors are returned with status and key.
1065
1026
  writeJson(res, error?.status ?? 500, { error: error?.key ?? error?.message ?? 'start-failed' })
1066
1027
  }
1067
1028
  return
@@ -1070,13 +1031,7 @@ export function apply(ctx, config) {
1070
1031
  try {
1071
1032
  const input = await readBody(req)
1072
1033
  if (req.method === 'POST' && action === 'move') {
1073
- // Ручной перенос — КОМАНДА агенту, а не запись о состоянии.
1074
- // Автоматика (сверка, инструмент, жизненный цикл) сюда не приходит:
1075
- // она двигает карточку мимо этого маршрута.
1076
- //
1077
- // Команду отдаём ДО переноса: остановка должна случиться раньше, чем
1078
- // доска объявит работу прекращённой, а её исход становится
1079
- // пояснением к единственному переходу в журнале.
1034
+ // Manual move dispatches stop command to agent prior to updating card column.
1080
1035
  const was = store.getTask(id)
1081
1036
  const acted = was !== undefined && input.column !== was.column
1082
1037
  ? dispatchMove({
@@ -1246,8 +1201,7 @@ export function apply(ctx, config) {
1246
1201
  store.updateTaskRun(task.id, latestRun.id, { endedAt: Date.now(), result: 'cancelled' })
1247
1202
  }
1248
1203
  syncPower()
1249
- // Остановка — событие, и в журнале ей место наравне с переносом:
1250
- // иначе прерванная работа выглядит просто оборвавшейся сама.
1204
+ // Stop event recorded in transition history.
1251
1205
  store.addTransition({
1252
1206
  taskId: task.id, fromCol: task.column, toCol: task.column,
1253
1207
  source: 'manual', detail: STOP_DETAIL.stopped,
@@ -1259,8 +1213,7 @@ export function apply(ctx, config) {
1259
1213
  if (req.method === 'POST' && action === 'assign') {
1260
1214
  const was = store.getTask(id)
1261
1215
  if (was === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1262
- // Пустой логин снимает назначение; слово «me» просит того, чьим
1263
- // токеном ходит доска: своего понятия пользователя у харнесса нет.
1216
+ // Empty login unassigns; 'me' resolves authenticated token identity.
1264
1217
  let login = typeof input.login === 'string' ? input.login.trim() : ''
1265
1218
  if (login === 'me') {
1266
1219
  login = await whoAmI()
@@ -1268,8 +1221,7 @@ export function apply(ctx, config) {
1268
1221
  }
1269
1222
  const out = assignTask({ store, id, login })
1270
1223
  if (out.task !== undefined && config.pushToGitea !== false) {
1271
- // В Gitea уходит очередью: доска не ждёт сеть и работает, когда
1272
- // Gitea лежит.
1224
+ // Queue assignment update to outbox for offline resilience.
1273
1225
  outbox.push(id, planAssign(out.task, login))
1274
1226
  }
1275
1227
  reply(res, out)
@@ -1310,13 +1262,12 @@ export function apply(ctx, config) {
1310
1262
  },
1311
1263
  }), 'dsh-kanban: task routes')
1312
1264
 
1313
- // Готовность Gitea проверяется на каждом запросе, а не при загрузке: адрес и
1314
- // учётная запись правятся в карточке настроек на ходу, и маршруты обязаны
1315
- // внятно объяснять причину, а не отвечать 404.
1265
+ // Check Gitea readiness per request to handle dynamically updated credentials.
1316
1266
  ctx.effect(() => ctx.webServer.register({
1317
1267
  kind: 'exact',
1318
1268
  path: '/dsh-kanban/gitea/repos',
1319
1269
  handler: async (req, res) => {
1270
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1320
1271
  const url = urlOf(req)
1321
1272
  reply(res, await searchRepos({ gitea: git, query: url.searchParams.get('q') || '' }))
1322
1273
  },
@@ -1326,6 +1277,7 @@ export function apply(ctx, config) {
1326
1277
  kind: 'exact',
1327
1278
  path: '/dsh-kanban/gitea/issues',
1328
1279
  handler: async (req, res) => {
1280
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1329
1281
  const url = urlOf(req)
1330
1282
  reply(res, await listImportable({
1331
1283
  gitea: git,
@@ -1356,13 +1308,12 @@ export function apply(ctx, config) {
1356
1308
  },
1357
1309
  }), 'dsh-kanban: issue import')
1358
1310
 
1359
- // Провайдеры отдаются сразу, модели — только для запрошенного провайдера:
1360
- // перечень моделей провайдер отдаёт по сети, и опрашивать все разом ради
1361
- // одного выпадающего списка незачем.
1311
+ // Providers returned immediately; models queried on-demand for selected provider.
1362
1312
  ctx.effect(() => ctx.webServer.register({
1363
1313
  kind: 'exact',
1364
1314
  path: '/dsh-kanban/models',
1365
1315
  handler: async (req, res) => {
1316
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1366
1317
  const current = ctx.get('agentDefaultModel')?.currentSelection?.() ?? null
1367
1318
  const llmService = ctx.get('llm')
1368
1319
  let providers = []
@@ -1380,8 +1331,7 @@ export function apply(ctx, config) {
1380
1331
  models: (rows ?? []).map((m) => ({ id: m.id, name: m.name ?? m.id })),
1381
1332
  })
1382
1333
  } catch {
1383
- // Провайдер может быть не настроен или недоступен: это не поломка
1384
- // доски, а пустой список с сохранённым выбором провайдера.
1334
+ // Provider errors return empty model list while preserving selected provider.
1385
1335
  writeJson(res, 200, { current, providers, models: [] })
1386
1336
  }
1387
1337
  },
@@ -1391,6 +1341,7 @@ export function apply(ctx, config) {
1391
1341
  kind: 'prefix',
1392
1342
  path: '/dsh-kanban/session',
1393
1343
  handler: (req, res) => {
1344
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1394
1345
  const m = /^\/dsh-kanban\/session\/([^/]+)\/task\/?$/.exec(urlOf(req).pathname)
1395
1346
  if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
1396
1347
  reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]), agents: ctx.agents }))