@goodandready/dsh-kanban 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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,
@@ -102,46 +101,51 @@ 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
  */
122
+ // Ensure .volatile() helper exists on Schemastery schema prototype
123
+ try {
124
+ const schemaProto = Object.getPrototypeOf(z.boolean())
125
+ if (schemaProto && typeof schemaProto.volatile !== 'function') {
126
+ schemaProto.volatile = function () {
127
+ return typeof this.extra === 'function' ? this.extra('volatile', true) : this
128
+ }
129
+ }
130
+ } catch {
131
+ // safe fallback
132
+ }
133
+
130
134
  export const Config = z.object(Object.fromEntries(
131
135
  Object.entries(CONFIG_DEFAULTS).map(([field, fallback]) => {
132
- // Узел схемы выбирается по типу умолчания. Пропустить здесь булево значит
133
- // объявить его строкой — и плагин не загрузится вовсе с «invalid config».
136
+ // Node type derived from default value type.
134
137
  let node = typeof fallback === 'number' ? z.number()
135
138
  : typeof fallback === 'boolean' ? z.boolean()
136
139
  : z.string()
137
140
  if (field === 'giteaTokenRef' || field === 'webhookSecretRef' || field === 'githubTokenRef' || field === 'githubWebhookSecretRef') {
138
141
  node = node.role('credential-ref')
139
142
  }
140
- return [field, node.default(fallback).description(CONFIG_HINTS[field] ?? '')]
143
+ const described = node.default(fallback).description(CONFIG_HINTS[field] ?? '')
144
+ return [field, typeof described.volatile === 'function' ? described.volatile() : described]
141
145
  }),
142
146
  ))
143
147
 
144
- /** Каталог хранилища. Абсолютных путей в коде нет — только переменная и дом. */
148
+ /** Storage directory path resolved from environment variable or user home directory. */
145
149
  export function storeDir() {
146
150
  return process.env.DSH_KANBAN_DIR || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'kanban')
147
151
  }
@@ -153,7 +157,7 @@ function writeJson(res, code, body) {
153
157
  } catch { /* socket closed */ }
154
158
  }
155
159
 
156
- /** Ответ обработчика: либо объект с `error` и `status`, либо полезная нагрузка. */
160
+ /** Handler response helper: either an error object or success payload. */
157
161
  function reply(res, out) {
158
162
  if (out && typeof out === 'object' && typeof out.error === 'string') {
159
163
  writeJson(res, out.status ?? 400, { error: out.error })
@@ -185,11 +189,9 @@ function readBody(req) {
185
189
  }
186
190
 
187
191
  /**
188
- * Сырое тело запроса.
189
- *
190
- * Подпись вебхука считается по БАЙТАМ доставки. Разобрать JSON и собрать его
191
- * обратно — значит получить другую последовательность байт и не сойтись в
192
- * подписи на пустом месте.
192
+ * Read raw request body bytes.
193
+ * Webhook signatures are computed over delivery byte buffers; parsing and re-serializing
194
+ * could alter byte sequences and fail signature verification.
193
195
  */
194
196
  function readRaw(req) {
195
197
  return new Promise((resolve, reject) => {
@@ -217,9 +219,8 @@ export function apply(ctx, config) {
217
219
  let effective = withDefaults(config)
218
220
  const getConfig = () => effective
219
221
 
220
- // Часть настроек управляет живыми подписками: промежутком сверки и наличием
221
- // инструмента у агента. Прочитать их один раз при загрузке — значит сделать
222
- // переключатель в карточке настроек безответным до перезапуска харнесса.
222
+ // Settings control live subscriptions: sync intervals and agent tools.
223
+ // Dynamic watcher re-evaluates configurations on change without requiring restart.
223
224
  const rearmers = new Set()
224
225
  const rearm = () => {
225
226
  for (const again of rearmers) {
@@ -227,16 +228,59 @@ export function apply(ctx, config) {
227
228
  }
228
229
  }
229
230
 
231
+ let settingsSvc = null
232
+
233
+ const readServedConfig = () => {
234
+ if (typeof settingsSvc?.describe !== 'function') return null
235
+ try {
236
+ const row = settingsSvc.describe().find((r) => r?.ns === SETTINGS_NAMESPACE)
237
+ if (!row || row.status !== 'ready' || !row.value) return null
238
+ return withDefaults(row.value)
239
+ } catch (err) {
240
+ ctx.logger?.warn?.(`dsh-kanban: settings describe failed: ${err?.message || err}`)
241
+ return null
242
+ }
243
+ }
244
+
230
245
  ctx.inject(['settings'], (sctx) => {
231
- const scope = sctx.settings.register(SETTINGS_NAMESPACE, Config, { base: effective })
232
- const read = () => withDefaults(scope.get() ?? effective)
233
- effective = read()
234
- ctx.effect(() => scope.watch(() => { effective = read(); rearm() }), 'dsh-kanban: settings watcher')
246
+ try {
247
+ settingsSvc = sctx?.settings ?? sctx?.get?.('settings') ?? null
248
+ const served = readServedConfig()
249
+ if (served) {
250
+ effective = served
251
+ rearm()
252
+ }
253
+ } catch (err) {
254
+ ctx.logger?.warn?.(`dsh-kanban: settings service unavailable: ${err?.message || err}`)
255
+ }
235
256
  })
236
257
 
237
- // Клиент Gitea свой: токен всё равно живёт в службе учётных данных ядра, а
238
- // шесть вызовов REST дешевле зависимости от чужого выпуска. Значение токена
239
- // наружу не отдаётся — в настройках только ИМЯ учётной записи.
258
+ // Listen for config and volatile updates from host
259
+ if (typeof ctx?.on === 'function') {
260
+ const applyPatch = (updated) => {
261
+ if (updated && typeof updated === 'object') {
262
+ const incoming = updated[SETTINGS_NAMESPACE] ?? updated
263
+ if (incoming && typeof incoming === 'object') {
264
+ try {
265
+ effective = withDefaults({ ...effective, ...incoming })
266
+ rearm()
267
+ } catch { /* ignore invalid update */ }
268
+ }
269
+ }
270
+ }
271
+ if (typeof ctx.effect === 'function') {
272
+ ctx.effect(() => ctx.on('loader/volatile-update', applyPatch))
273
+ ctx.effect(() => ctx.on('config', applyPatch))
274
+ ctx.effect(() => ctx.on('settings/document-updated', applyPatch))
275
+ } else {
276
+ ctx.on('loader/volatile-update', applyPatch)
277
+ ctx.on('config', applyPatch)
278
+ ctx.on('settings/document-updated', applyPatch)
279
+ }
280
+ }
281
+
282
+ // Dedicated Gitea client resolves token via core credentials service.
283
+ // Secret token values are never exposed externally; only the credential reference name is stored.
240
284
  const resolveSecret = async (name) => {
241
285
  if (typeof name !== 'string' || name.trim() === '') return ''
242
286
  try {
@@ -263,8 +307,7 @@ export function apply(ctx, config) {
263
307
  getConfig,
264
308
  })
265
309
 
266
- // Хранилище открывается один раз и закрывается уборщиком того же эффекта:
267
- // выгрузка плагина обязана отпустить файл базы.
310
+ // Database store opens once and is closed on effect disposal.
268
311
  let store
269
312
  const taskService = createKanbanTaskService({ getStore: () => store })
270
313
  if (typeof ctx.provide === 'function') ctx.provide('dshKanban', taskService)
@@ -273,7 +316,7 @@ export function apply(ctx, config) {
273
316
  return () => { try { store.close() } catch { /* already closed */ } }
274
317
  }, 'dsh-kanban: task store')
275
318
 
276
- // Кроссплатформенный ингибитор сна системы во время работы задач
319
+ // Cross-platform system sleep inhibitor while tasks are actively running
277
320
  const powerInhibitor = new PowerInhibitor({
278
321
  platform: process.platform,
279
322
  pid: process.pid,
@@ -312,7 +355,7 @@ export function apply(ctx, config) {
312
355
  return () => { powerInhibitor.destroy() }
313
356
  }, 'dsh-kanban: power inhibitor')
314
357
 
315
- // Инъекция секции доски в системный промпт агента
358
+ // Inject board instructions into agent system prompt
316
359
  let disposePromptSection
317
360
  const syncPromptSection = () => {
318
361
  if (disposePromptSection) {
@@ -339,10 +382,10 @@ export function apply(ctx, config) {
339
382
  return () => { if (disposePromptSection) { try { disposePromptSection() } catch { /* cleanup best effort */ } } }
340
383
  }, 'dsh-kanban: system prompt section')
341
384
 
342
- // Признак ожидания, исходы сессий и PROGRESSDUMP по событиям сессии.
385
+ // Track waiting flags, session outcomes, and progress dump from session events.
343
386
  ctx.effect(() => ctx.on('session/event', (session, event) => {
344
387
  try {
345
- // Автосинхронизация сессий чата DSH в карточки канбана (#225)
388
+ // Auto-sync DSH chat sessions into kanban cards (#225)
346
389
  const sessionIdStr = String(session?.id ?? '')
347
390
  if (
348
391
  getConfig().autoSyncSessions === true &&
@@ -395,7 +438,7 @@ export function apply(ctx, config) {
395
438
  error: event?.error,
396
439
  })
397
440
  syncPower()
398
- // Если сессия завершила шаг или упала — пробуем продвинуть очередь задач (#214)
441
+ // Dispatch next queued task when a session completes or fails (#214)
399
442
  if (event?.type === 'turn/end' || event?.type === 'turn/completed' || event?.type === 'session/error' || event?.type === 'turn/error') {
400
443
  dispatchNextQueuedTask({
401
444
  agents: ctx.agents,
@@ -414,7 +457,7 @@ export function apply(ctx, config) {
414
457
  }
415
458
  }), 'dsh-kanban: session events')
416
459
 
417
- // Фоновый шедулер cron для задач
460
+ // Background cron scheduler for scheduled tasks
418
461
  ctx.effect(() => {
419
462
  let cronTimer
420
463
  const armCron = () => {
@@ -482,13 +525,9 @@ export function apply(ctx, config) {
482
525
  return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
483
526
  }, 'dsh-kanban: cron scheduler')
484
527
 
485
- // Периодическая сверка с Gitea. Ноль в настройках отключает её целиком, а
486
- // перекрытие запусков не допускается: медленный инстанс иначе копил бы
487
- // очередь сверок вместо одной идущей.
488
- // Очередь отправки в Gitea. Перенос карточки не ждёт сети: доска обязана
489
- // работать и при недоступном Gitea, а неудачная отправка повторяется и, если
490
- // так и не удалась, попадает в журнал задачи.
491
- const outbox = createOutbox({ gitea: git, store, logger: ctx.logger })
528
+ // Periodic Gitea reconciliation. 0 disables it; overlapping executions are prevented.
529
+ // Outbound push queue to Gitea. Card moves do not block on network; failures retry in background.
530
+ const outbox = createOutbox({ gitea: git, get store() { return store }, logger: ctx.logger })
492
531
 
493
532
  const queueOutbound = (taskId, column) => {
494
533
  if (getConfig().pushToGitea !== true) return
@@ -499,11 +538,8 @@ export function apply(ctx, config) {
499
538
  }
500
539
 
501
540
  /**
502
- * Владелец для коротких имён в списке отслеживаемых репозиториев.
503
- *
504
- * Берётся из задач, уже лежащих на доске: другого источника у плагина нет,
505
- * а спрашивать владельца отдельной настройкой значит просить дважды одно и
506
- * то же.
541
+ * Default repository owner for short repository names.
542
+ * Inferred from existing board tasks when not explicitly configured.
507
543
  */
508
544
  let discoveredOwner
509
545
  const defaultOwner = async () => {
@@ -513,15 +549,13 @@ export function apply(ctx, config) {
513
549
  if (typeof task.owner === 'string' && task.owner !== '') return task.owner
514
550
  }
515
551
  if (discoveredOwner !== undefined) return discoveredOwner
516
- // Спрашиваем у Gitea, и только когда организация ровно одна: при
517
- // нескольких гадание молча смотрело бы не туда, а это хуже отказа.
552
+ // Fall back to Gitea organization if exactly one is accessible to the token.
518
553
  const orgs = await gitea.listOrgs()
519
554
  if (orgs.length === 1) { discoveredOwner = orgs[0]; return discoveredOwner }
520
555
  return undefined
521
556
  }
522
557
 
523
- // Состояние сверки видно человеку в шапке доски: доска, переставшая
524
- // обновляться, не имеет права молчать об этом.
558
+ // Sync telemetry state visible in board header.
525
559
  const syncState = createSyncState()
526
560
 
527
561
  let syncing = false
@@ -530,9 +564,8 @@ export function apply(ctx, config) {
530
564
  syncing = true
531
565
  syncState.started()
532
566
  try {
533
- // Порядок важен. Сперва забираем новое: свежая задача должна попасть на
534
- // доску и тут же быть сверена, а не ждать следующего прохода. Архивация
535
- // последней — она смотрит на итог, а не на исходное.
567
+ // Order of operations: intake new issues first, reconcile active tasks, archive overdue.
568
+ // Inspect final outcome rather than initial state.
536
569
  const mode = String(getConfig()?.gitProvider || 'auto').toLowerCase()
537
570
  const providersToSync = []
538
571
  if (mode === 'github') {
@@ -562,18 +595,15 @@ export function apply(ctx, config) {
562
595
  }
563
596
  const out = await syncAll({ gitea: git, store, logger: ctx.logger })
564
597
  const tidied = archiveOverdue({ store, config: getConfig() })
565
- // Отложенные отправки уходят той же волной: отдельный таймер ради них
566
- // означал бы второй источник обращений к Gitea без всякой пользы.
598
+ // Outbox flushes run in the same cycle to minimize redundant Gitea API calls.
567
599
  const pushed = await outbox.flush()
568
- // Беда подхвата — такая же слепота доски, как беда сверки: задачи не
569
- // приезжают, а человеку об этом никто не говорит.
600
+ // Intake errors are reported in sync telemetry to keep failures visible.
570
601
  syncState.finished(out.problem === undefined && taken.problem !== undefined
571
602
  ? { ...out, problem: taken.problem }
572
603
  : out)
573
604
  return { ...out, pushed, taken, tidied }
574
605
  } catch (error) {
575
- // Сверка не начиналась вовсе — тоже беда, и своя: так выглядит
576
- // неразрешённый токен или отсутствующий клиент Gitea.
606
+ // Sync failure to start represents missing client or unconfigured credentials.
577
607
  syncState.failedToStart('sync', error?.message)
578
608
  throw error
579
609
  } finally {
@@ -620,17 +650,14 @@ export function apply(ctx, config) {
620
650
  ctx.effect(() => ctx.webServer.register({
621
651
  kind: 'exact',
622
652
  path: '/dsh-kanban/presets',
623
- // Обе службы необязательны: развёртывание может не собирать ни профили,
624
- // ни уровни доступа. Пустой список — не поломка, а «выбирать не из чего»,
625
- // и браузер просто не рисует поле.
653
+ // Both services are optional in minimal environments. Empty lists render gracefully.
626
654
  handler: async (req, res) => {
627
655
  const presets = ctx.get('agentPresets')
628
656
  const permissions = ctx.get('permissionPresets')
629
657
  let agentPresets = []
630
658
  try {
631
659
  const rows = presets === undefined ? [] : await presets.list()
632
- // Сломанный профиль остаётся в списке ядра, но выбрать его нельзя:
633
- // сессия на нём не соберётся, а отказ прилетит уже после запуска.
660
+ // Filter out invalid presets that would fail session creation.
634
661
  agentPresets = rows.filter((row) => row?.broken === undefined).map((row) => ({
635
662
  id: row.id, name: row.name || row.id, description: row.description,
636
663
  }))
@@ -767,9 +794,7 @@ export function apply(ctx, config) {
767
794
  },
768
795
  }), 'dsh-kanban: gitea webhook')
769
796
 
770
- // Инструмент для агента. По умолчанию ВЫКЛЮЧЕН: скилл воркфлоу пока
771
- // запрещает этому CLI трогать канбан, и включить инструмент раньше правки
772
- // скилла — значит толкать агента на нарушение.
797
+ // Agent tool definition. Default disabled; workflow skills explicitly enable it.
773
798
  ctx.effect(() => {
774
799
  let unregister
775
800
  const arm = () => {
@@ -779,8 +804,7 @@ export function apply(ctx, config) {
779
804
  unregister = registerBoardTool()
780
805
  }
781
806
 
782
- // Один выключатель на оба инструмента доски: разделять их значило бы
783
- // множить настройки ради разницы, которой человек не почувствует.
807
+ // Single setting switch controls both board tools.
784
808
  const registerBoardTool = () => {
785
809
  const off = [
786
810
  ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig }))),
@@ -801,8 +825,7 @@ export function apply(ctx, config) {
801
825
  }, 'dsh-kanban: board tool')
802
826
 
803
827
  /**
804
- * Состояние живого агента сессии либо `undefined`, если агента нет.
805
- * Отказ реестра на незнакомый идентификатор ловит сборка доски.
828
+ * Return active agent state or undefined if session is inactive.
806
829
  */
807
830
  const liveOf = (sessionId) => ctx.agents.get(sessionId)?.status
808
831
 
@@ -819,27 +842,15 @@ export function apply(ctx, config) {
819
842
  repo: url.searchParams.get('repo') || undefined,
820
843
  liveOf,
821
844
  sync: syncState.snapshot(),
822
- // Фактический корень проектов едет с доской: браузер показывает его в
823
- // окне запуска, когда человек не задал корень настройкой, — иначе
824
- // сессия молча поднимается в рабочей папке харнесса.
845
+ // Resolved project root passed with board payload for session launch modal.
825
846
  projectRoot: rootOf(getConfig()),
826
847
  }))
827
848
  },
828
849
  }), 'dsh-kanban: board route')
829
850
 
830
- // Снимок доски — ОДИН ресурс с двумя действиями, а не две ручки.
831
- //
832
- // Прежде выгрузка звалась `/export`, а загрузка занимала `/import` — тот
833
- // самый путь, на котором с первого дня живёт импорт issue из Gitea. Два
834
- // обработчика на одном пути означают, что один из них не отвечает вовсе, и
835
- // какой именно — решает порядок регистрации, а не мы.
851
+ // Snapshot endpoint handles both export and import.
836
852
  /**
837
- * Кто мы для Gitea, с памятью на время работы.
838
- *
839
- * Логин нужен и «взять себе», и запуску работы. Спрашивать его у Gitea
840
- * каждый раз значит добавить в путь запуска ещё одну сеть, которая может не
841
- * ответить; поэтому спрашиваем один раз и помним. Не ответила — работаем без
842
- * назначения, а не отказываем в запуске.
853
+ * Cached identity for Gitea client to avoid redundant API lookups on task claim.
843
854
  */
844
855
  let knownLogin
845
856
  const whoAmI = async () => {
@@ -870,8 +881,7 @@ export function apply(ctx, config) {
870
881
  ctx.effect(() => ctx.webServer.register({
871
882
  kind: 'exact',
872
883
  path: '/dsh-kanban/whoami',
873
- // Пустой логин — законный ответ: Gitea может быть не настроен или не
874
- // отвечать. Браузер по нему просто не рисует «Мои», а не показывает ноль.
884
+ // Empty login indicates unconfigured or unreachable Gitea; client omits "Mine" filter.
875
885
  handler: async (req, res) => {
876
886
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
877
887
  reply(res, { login: await whoAmI() })
@@ -899,11 +909,11 @@ export function apply(ctx, config) {
899
909
  kind: 'exact',
900
910
  path: '/dsh-kanban/events',
901
911
  handler: (req, res) => {
902
- eventHub.handleSseRequest(req, res)
912
+ eventHub.handleSseRequest(req, res, { isTrustedRequest })
903
913
  },
904
914
  }), 'dsh-kanban: sse events stream')
905
915
 
906
- // Очистка SSE-клиентов и heartbeat таймера при выгрузке плагина (#282)
916
+ // Clean up SSE client connections and heartbeat timer on plugin disposal (#282)
907
917
  if (typeof ctx.on === 'function') {
908
918
  ctx.on('dispose', () => {
909
919
  eventHub.closeAll()
@@ -944,14 +954,13 @@ export function apply(ctx, config) {
944
954
  }), 'dsh-kanban: board snapshot')
945
955
 
946
956
  ctx.effect(() => ctx.webServer.register({
947
- // Префикс регистрируется БЕЗ завершающего слэша: со слэшем сопоставление
948
- // сводится к равенству пути, и всё, что глубже, уходит в статику.
957
+ // Prefix registered without trailing slash for sub-path routing.
949
958
  kind: 'prefix',
950
959
  path: '/dsh-kanban/task',
951
960
  handler: async (req, res) => {
952
961
  const pathname = urlOf(req).pathname
953
962
 
954
- // Сам путь без идентификатора — создание своей задачи.
963
+ // Path without ID handles local task creation.
955
964
  if (pathname === '/dsh-kanban/task' || pathname === '/dsh-kanban/task/') {
956
965
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
957
966
  if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
@@ -970,8 +979,7 @@ export function apply(ctx, config) {
970
979
  if (req.method === 'GET' && action === 'log') { reply(res, taskLog({ store, id })); return }
971
980
  if (req.method === 'GET' && action === 'body') { reply(res, taskBody({ store, id })); return }
972
981
 
973
- // Заготовку сообщения отдаём отсюда, а не собираем второй раз в браузере:
974
- // иначе шаблон окажется в двух местах и однажды разойдётся.
982
+ // Generate start prompt on server for template consistency.
975
983
  if (req.method === 'GET' && action === 'message') {
976
984
  const task = store.getTask(id)
977
985
  if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
@@ -980,8 +988,7 @@ export function apply(ctx, config) {
980
988
  }
981
989
  if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
982
990
  if (req.method === 'DELETE' && action === undefined) { reply(res, deleteTask({ store, id })); return }
983
- // Правка полей карточки: title, body, метки, приоритет. Приоритет —
984
- // локальное свойство доски, в Gitea не пишется (updateTask это знает).
991
+ // Card fields update: title, body, labels, priority. Priority is board-local.
985
992
  if (req.method === 'POST' && action === 'update') {
986
993
  try {
987
994
  const input = await readBody(req)
@@ -1057,8 +1064,7 @@ export function apply(ctx, config) {
1057
1064
  config: getConfig(),
1058
1065
  provider: picked.provider,
1059
1066
  model: picked.model,
1060
- // Идентификатор мнётся ЛЕНИВО: при продолжении новая сессия не
1061
- // поднимается вовсе, и заранее занятое имя осталось бы висеть.
1067
+ // Mint session ID lazily to avoid dangling IDs on resume.
1062
1068
  mintSessionId: () => SessionId(`kanban-${id}-${randomUUID()}`),
1063
1069
  createMessage: createUserMessage,
1064
1070
  logger: ctx.logger,
@@ -1073,8 +1079,7 @@ export function apply(ctx, config) {
1073
1079
  reply(res, started)
1074
1080
  } catch (error) {
1075
1081
  ctx.logger?.warn?.(`dsh-kanban: task launch failed: ${error?.message}`)
1076
- // Отказ по уровню доступа называется своим именем: «не удалось» на
1077
- // месте «сессия пошла бы с большими правами» — это молчание.
1082
+ // Explicit permission errors are returned with status and key.
1078
1083
  writeJson(res, error?.status ?? 500, { error: error?.key ?? error?.message ?? 'start-failed' })
1079
1084
  }
1080
1085
  return
@@ -1083,13 +1088,7 @@ export function apply(ctx, config) {
1083
1088
  try {
1084
1089
  const input = await readBody(req)
1085
1090
  if (req.method === 'POST' && action === 'move') {
1086
- // Ручной перенос — КОМАНДА агенту, а не запись о состоянии.
1087
- // Автоматика (сверка, инструмент, жизненный цикл) сюда не приходит:
1088
- // она двигает карточку мимо этого маршрута.
1089
- //
1090
- // Команду отдаём ДО переноса: остановка должна случиться раньше, чем
1091
- // доска объявит работу прекращённой, а её исход становится
1092
- // пояснением к единственному переходу в журнале.
1091
+ // Manual move dispatches stop command to agent prior to updating card column.
1093
1092
  const was = store.getTask(id)
1094
1093
  const acted = was !== undefined && input.column !== was.column
1095
1094
  ? dispatchMove({
@@ -1232,7 +1231,7 @@ export function apply(ctx, config) {
1232
1231
  agentPreset: input?.agentPreset || task.agentPreset,
1233
1232
  permission: input?.permission || task.permission,
1234
1233
  permissions: ctx.get('permissionPresets'),
1235
- claimAs: req.headers['x-dsh-user'] || (await whoAmI()),
1234
+ claimAs: await whoAmI(),
1236
1235
  workspaceRegistry: ctx.get('workspaceRegistry'),
1237
1236
  resume: true,
1238
1237
  })
@@ -1259,8 +1258,7 @@ export function apply(ctx, config) {
1259
1258
  store.updateTaskRun(task.id, latestRun.id, { endedAt: Date.now(), result: 'cancelled' })
1260
1259
  }
1261
1260
  syncPower()
1262
- // Остановка — событие, и в журнале ей место наравне с переносом:
1263
- // иначе прерванная работа выглядит просто оборвавшейся сама.
1261
+ // Stop event recorded in transition history.
1264
1262
  store.addTransition({
1265
1263
  taskId: task.id, fromCol: task.column, toCol: task.column,
1266
1264
  source: 'manual', detail: STOP_DETAIL.stopped,
@@ -1272,8 +1270,7 @@ export function apply(ctx, config) {
1272
1270
  if (req.method === 'POST' && action === 'assign') {
1273
1271
  const was = store.getTask(id)
1274
1272
  if (was === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1275
- // Пустой логин снимает назначение; слово «me» просит того, чьим
1276
- // токеном ходит доска: своего понятия пользователя у харнесса нет.
1273
+ // Empty login unassigns; 'me' resolves authenticated token identity.
1277
1274
  let login = typeof input.login === 'string' ? input.login.trim() : ''
1278
1275
  if (login === 'me') {
1279
1276
  login = await whoAmI()
@@ -1281,8 +1278,7 @@ export function apply(ctx, config) {
1281
1278
  }
1282
1279
  const out = assignTask({ store, id, login })
1283
1280
  if (out.task !== undefined && config.pushToGitea !== false) {
1284
- // В Gitea уходит очередью: доска не ждёт сеть и работает, когда
1285
- // Gitea лежит.
1281
+ // Queue assignment update to outbox for offline resilience.
1286
1282
  outbox.push(id, planAssign(out.task, login))
1287
1283
  }
1288
1284
  reply(res, out)
@@ -1323,9 +1319,7 @@ export function apply(ctx, config) {
1323
1319
  },
1324
1320
  }), 'dsh-kanban: task routes')
1325
1321
 
1326
- // Готовность Gitea проверяется на каждом запросе, а не при загрузке: адрес и
1327
- // учётная запись правятся в карточке настроек на ходу, и маршруты обязаны
1328
- // внятно объяснять причину, а не отвечать 404.
1322
+ // Check Gitea readiness per request to handle dynamically updated credentials.
1329
1323
  ctx.effect(() => ctx.webServer.register({
1330
1324
  kind: 'exact',
1331
1325
  path: '/dsh-kanban/gitea/repos',
@@ -1371,9 +1365,7 @@ export function apply(ctx, config) {
1371
1365
  },
1372
1366
  }), 'dsh-kanban: issue import')
1373
1367
 
1374
- // Провайдеры отдаются сразу, модели — только для запрошенного провайдера:
1375
- // перечень моделей провайдер отдаёт по сети, и опрашивать все разом ради
1376
- // одного выпадающего списка незачем.
1368
+ // Providers returned immediately; models queried on-demand for selected provider.
1377
1369
  ctx.effect(() => ctx.webServer.register({
1378
1370
  kind: 'exact',
1379
1371
  path: '/dsh-kanban/models',
@@ -1396,8 +1388,7 @@ export function apply(ctx, config) {
1396
1388
  models: (rows ?? []).map((m) => ({ id: m.id, name: m.name ?? m.id })),
1397
1389
  })
1398
1390
  } catch {
1399
- // Провайдер может быть не настроен или недоступен: это не поломка
1400
- // доски, а пустой список с сохранённым выбором провайдера.
1391
+ // Provider errors return empty model list while preserving selected provider.
1401
1392
  writeJson(res, 200, { current, providers, models: [] })
1402
1393
  }
1403
1394
  },