@goodandready/dsh-kanban 0.1.35 → 0.2.0

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.
@@ -67,6 +67,13 @@
67
67
  - Фильтрация сессий субагентов: сессии с маркером subagent (origin === 'subagent' или parentId !== undefined) исключаются из liveSessions и не перезаписывают состояние корневых задач (#215).
68
68
  ## Locked Decisions
69
69
 
70
+ ### 2026-09-08: Релиз v0.2.0 — Milestone (SSE, Chat Sync, Templates, Two-Column Drawer, Multi-Repo) (#210, #219, #220, #222, #225)
71
+ - **Live SSE Channel (`/dsh-kanban/events`)**: Постоянное SSE-соединение с автоматическим переподключением для мгновенного пуша обновлений доски (`task:change`, `board:change`) без избыточного поллинга (#222).
72
+ - **Auto-sync Sessions (`autoSyncSessions`)**: Опциональная автоматическая синхронизация стандартных диалогов чата DSH в карточки доски канбана с надежной фильтрацией внутренних сессий и субагентов (#225).
73
+ - **Task Templates Library (`/dsh-kanban/templates`)**: Библиотека из 5 встроенных типовых шаблонов (Feature, Bugfix, Refactor, Security, Release) с предзаполненными чеклистами DoD и шаблонами промптов (#220).
74
+ - **Two-Column Drawer Layout**: Адаптивный двухколоночный макет для TaskDrawer на экранах шириной от 960px с разделением метаданных и контентной области (#219).
75
+ - **Multi-Repo Workspace Mirroring**: Сканирование нескольких вложенных git-репозиториев (`findWorkspaceGitRepos`) и подготовка зеркальных воркдеревьев на единой ветке задачи (`prepareMultiRepoMirror`) (#210).
76
+
70
77
  ### 2026-09-08: Релиз Vector 4 (UX оператора, управление моделями и аналитика) (#217, #218, #221, #223, #228)
71
78
  - **reasoningEffort**: Добавлен селектор глубины рассуждения (low, medium, high) в карточке `TaskDrawer`, сохраняется в SQLite и передается в метаданные сессии DSH для моделей с рассуждением (DeepSeek-R1, OpenAI o1/o3).
72
79
  - **Экспорт в CSV с UTF-8 BOM**: Добавлен эндпоинт `GET /dsh-kanban/export.csv` и кнопка в тулбаре для выгрузки аналитики задач в Microsoft Excel без искажения кодировки.
package/lib/client.js CHANGED
@@ -806,6 +806,9 @@ window.__ModuleLoader__.load({
806
806
  // оказывается под ними — нажать нельзя, а выглядит как «ничего не работает».
807
807
  '.dkb-modal{position:absolute;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;padding:24px 24px 84px;box-sizing:border-box;z-index:70}' +
808
808
  '.dkb-panel{width:min(720px,100%);max-height:100%;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:16px;box-sizing:border-box;overflow-y:auto;display:flex;flex-direction:column;gap:12px}' +
809
+ '@media (min-width: 960px){.dkb-panelWide{width:min(1100px,96%)}.dkb-drawerGrid{display:grid;grid-template-columns:300px 1fr;gap:20px;align-items:start}}' +
810
+ '.dkb-drawerSidebar{display:flex;flex-direction:column;gap:12px;border-right:1px solid var(--dsw-alias-border-l2);padding-right:16px}' +
811
+ '.dkb-drawerMain{display:flex;flex-direction:column;gap:12px;min-width:0}' +
809
812
  '.dkb-panelBody{white-space:pre-wrap;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.5;margin:0}' +
810
813
  '.dkb-log{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}' +
811
814
  '.dkb-logRow{color:var(--dsw-alias-label-secondary);font-size:12px}' +
@@ -875,6 +878,8 @@ window.__ModuleLoader__.load({
875
878
  'field.announceToAgent': 'Announce board in system prompt',
876
879
  'field.maxConcurrentSessions': 'Max concurrent agent sessions',
877
880
  'field.workspaceClaimBoundaries': 'Workspace claim boundaries',
881
+ 'field.autoSyncSessions': 'Auto-sync external chat sessions',
882
+ 'hint.autoSyncSessions': 'Automatically create board cards for standard DSH chat conversations.',
878
883
  'hint.announceToAgent': 'Injects guidance section into the agent system prompt.',
879
884
  'task.runs': 'Execution History',
880
885
  'task.noRuns': 'No runs yet',
@@ -1141,6 +1146,13 @@ window.__ModuleLoader__.load({
1141
1146
  'reasoning.high': 'High (Deep Thinking)',
1142
1147
  'action.exportCsv': 'Export CSV (Excel)',
1143
1148
  'slash.hint': 'Type / for commands & skills',
1149
+ 'template.label': 'Task Template',
1150
+ 'template.none': 'None (Empty template)',
1151
+ 'template.feature': 'Feature (New functionality)',
1152
+ 'template.bugfix': 'Bugfix (Defect fix)',
1153
+ 'template.refactor': 'Refactor (Code cleanup)',
1154
+ 'template.security': 'Security (Audit & CVE)',
1155
+ 'template.release': 'Release (Release preparation)',
1144
1156
  'diff.title': 'Git Changes & Diff',
1145
1157
  'diff.empty': 'No changes detected in worktree.',
1146
1158
  'diff.commits': 'Commits ({n})',
@@ -1215,6 +1227,8 @@ window.__ModuleLoader__.load({
1215
1227
  'field.announceToAgent': '在系统提示词中声明看板',
1216
1228
  'field.maxConcurrentSessions': '最大并发会话数',
1217
1229
  'field.workspaceClaimBoundaries': '工作区认领边界',
1230
+ 'field.autoSyncSessions': '自动同步外部聊天会话',
1231
+ 'hint.autoSyncSessions': '自动将 DSH 的标准聊天会话同步为看板任务卡片。',
1218
1232
  'hint.announceToAgent': '向 Agent 系统提示词注入看板与进度协作说明。',
1219
1233
  'task.runs': '执行历史',
1220
1234
  'task.noRuns': '暂无执行记录',
@@ -1481,6 +1495,13 @@ window.__ModuleLoader__.load({
1481
1495
  'reasoning.high': '高(深度推理)',
1482
1496
  'action.exportCsv': '导出 CSV (Excel)',
1483
1497
  'slash.hint': '输入 / 获取命令与技能',
1498
+ 'template.label': '任务模板',
1499
+ 'template.none': '无(空白模板)',
1500
+ 'template.feature': '功能特性 (Feature)',
1501
+ 'template.bugfix': '缺陷修复 (Bugfix)',
1502
+ 'template.refactor': '重构优化 (Refactor)',
1503
+ 'template.security': '安全审计 (Security)',
1504
+ 'template.release': '版本发布 (Release)',
1484
1505
  'diff.title': 'Git 变更与 Diff',
1485
1506
  'diff.empty': '工作区未检测到变更。',
1486
1507
  'diff.commits': '提交历史 ({n})',
@@ -1551,6 +1572,7 @@ window.__ModuleLoader__.load({
1551
1572
  { field: 'announceToAgent', kind: 'bool', group: 'general' },
1552
1573
  { field: 'maxConcurrentSessions', kind: 'number', group: 'limits' },
1553
1574
  { field: 'workspaceClaimBoundaries', kind: 'bool', group: 'general' },
1575
+ { field: 'autoSyncSessions', kind: 'bool', group: 'sync' },
1554
1576
  ]
1555
1577
 
1556
1578
  function hintKey(spec) {
@@ -2004,6 +2026,47 @@ window.__ModuleLoader__.load({
2004
2026
 
2005
2027
  React.useEffect(() => { load(board) }, [load, board])
2006
2028
 
2029
+ // Подписка на Server-Sent Events (SSE) для мгновенного обновления доски (#222).
2030
+ React.useEffect(() => {
2031
+ if (typeof window === 'undefined' || typeof window.EventSource !== 'function') return undefined
2032
+ let es = null
2033
+ let reconnectTimer = null
2034
+
2035
+ const connect = () => {
2036
+ try {
2037
+ es = new window.EventSource('/dsh-kanban/events')
2038
+ es.addEventListener('board:change', () => {
2039
+ load(board)
2040
+ })
2041
+ es.addEventListener('task:change', () => {
2042
+ load(board)
2043
+ })
2044
+ es.onerror = () => {
2045
+ if (es) {
2046
+ try { es.close() } catch {}
2047
+ es = null
2048
+ }
2049
+ if (!reconnectTimer) {
2050
+ reconnectTimer = setTimeout(() => {
2051
+ reconnectTimer = null
2052
+ connect()
2053
+ }, 5000)
2054
+ }
2055
+ }
2056
+ } catch {}
2057
+ }
2058
+
2059
+ connect()
2060
+
2061
+ return () => {
2062
+ if (reconnectTimer) clearTimeout(reconnectTimer)
2063
+ if (es) {
2064
+ try { es.close() } catch {}
2065
+ es = null
2066
+ }
2067
+ }
2068
+ }, [load, board])
2069
+
2007
2070
  // Просьба чипа «покажи вот эту». Забираем её один раз: повтор открывал
2008
2071
  // бы окно снова после каждого закрытия.
2009
2072
  const [wanted, setWanted] = React.useState('')
@@ -2650,7 +2713,7 @@ window.__ModuleLoader__.load({
2650
2713
  className: 'dkb-modal',
2651
2714
  onClick: () => setOpenTask(null),
2652
2715
  }, React.createElement('div', {
2653
- className: 'dkb-panel',
2716
+ className: 'dkb-panel dkb-panelWide',
2654
2717
  role: 'dialog', 'aria-modal': 'true',
2655
2718
  onClick: (e) => e.stopPropagation(),
2656
2719
  },
package/lib/config.js CHANGED
@@ -81,6 +81,7 @@ export const CONFIG_DEFAULTS = {
81
81
  announceToAgent: true,
82
82
  maxConcurrentSessions: 3,
83
83
  workspaceClaimBoundaries: true,
84
+ autoSyncSessions: false,
84
85
  }
85
86
 
86
87
  /** Пояснения к полям; повторно используются схемой и карточкой настроек. */
@@ -109,6 +110,7 @@ export const CONFIG_HINTS = {
109
110
  permissionGateEnabled: 'Требовать подтверждения человеком для запуска задач с повышенными правами.',
110
111
  sessionDefaultPermission: 'Базовый уровень прав без подтверждения. Права выше этого требуют одобрения.',
111
112
  announceToAgent: 'Добавлять секцию о доске задач в системный промпт агентов.',
113
+ autoSyncSessions: 'Автоматически создавать карточки задач для обычных сессий чата DSH.',
112
114
  }
113
115
 
114
116
  /** Поле настроек с пределом числа карточек; задан не у всех колонок. */
package/lib/events.js ADDED
@@ -0,0 +1,91 @@
1
+ // Broadcaster для Server-Sent Events (SSE) (#222).
2
+
3
+ export class KanbanEventHub {
4
+ constructor(options = {}) {
5
+ this.clients = new Set()
6
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs || 15000
7
+ this.heartbeatTimer = null
8
+ this._startHeartbeat()
9
+ }
10
+
11
+ _startHeartbeat() {
12
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
13
+ this.heartbeatTimer = setInterval(() => {
14
+ this._pingAll()
15
+ }, this.heartbeatIntervalMs)
16
+ if (this.heartbeatTimer.unref) {
17
+ this.heartbeatTimer.unref()
18
+ }
19
+ }
20
+
21
+ _pingAll() {
22
+ for (const client of this.clients) {
23
+ try {
24
+ client.write(':keepalive\n\n')
25
+ } catch {
26
+ this.clients.delete(client)
27
+ }
28
+ }
29
+ }
30
+
31
+ handleSseRequest(req, res) {
32
+ res.writeHead(200, {
33
+ 'Content-Type': 'text/event-stream',
34
+ 'Cache-Control': 'no-cache, no-transform',
35
+ 'Connection': 'keep-alive',
36
+ 'X-Accel-Buffering': 'no',
37
+ 'Access-Control-Allow-Origin': '*',
38
+ })
39
+
40
+ res.write(':connected\n\n')
41
+ this.clients.add(res)
42
+
43
+ const cleanup = () => {
44
+ this.clients.delete(res)
45
+ try { res.end() } catch {}
46
+ }
47
+
48
+ if (typeof req.on === 'function') req.on('close', cleanup)
49
+ if (typeof req.on === 'function') req.on('error', cleanup)
50
+ if (typeof res.on === 'function') res.on('error', cleanup)
51
+
52
+ return cleanup
53
+ }
54
+
55
+ broadcast(eventType, payload = {}) {
56
+ const data = JSON.stringify(payload)
57
+ const message = `event: ${eventType}\ndata: ${data}\n\n`
58
+ let sentCount = 0
59
+
60
+ for (const client of this.clients) {
61
+ try {
62
+ client.write(message)
63
+ sentCount++
64
+ } catch {
65
+ this.clients.delete(client)
66
+ }
67
+ }
68
+
69
+ return sentCount
70
+ }
71
+
72
+ getClientCount() {
73
+ return this.clients.size
74
+ }
75
+
76
+ closeAll() {
77
+ if (this.heartbeatTimer) {
78
+ clearInterval(this.heartbeatTimer)
79
+ this.heartbeatTimer = null
80
+ }
81
+ for (const client of this.clients) {
82
+ try {
83
+ client.write('event: close\ndata: {}\n\n')
84
+ client.end()
85
+ } catch {}
86
+ }
87
+ this.clients.clear()
88
+ }
89
+ }
90
+
91
+ export const eventHub = new KanbanEventHub()
package/lib/index.js CHANGED
@@ -51,6 +51,8 @@ import { computeNextRun } from './cron.js'
51
51
  import { PowerInhibitor } from './power.js'
52
52
  import { formatProgressDump, parseProgressDump } from './progress-dump.js'
53
53
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
54
+ import { eventHub } from './events.js'
55
+ import { TASK_TEMPLATES } from './templates.js'
54
56
 
55
57
  export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
56
58
 
@@ -264,6 +266,49 @@ export function apply(ctx, config) {
264
266
  // Признак ожидания, исходы сессий и PROGRESSDUMP по событиям сессии.
265
267
  ctx.effect(() => ctx.on('session/event', (session, event) => {
266
268
  try {
269
+ // Автосинхронизация сессий чата DSH в карточки канбана (#225)
270
+ const sessionIdStr = String(session?.id ?? '')
271
+ if (
272
+ getConfig().autoSyncSessions === true &&
273
+ session &&
274
+ !session.isSubagent &&
275
+ session.origin !== 'subagent' &&
276
+ session.parentId === undefined &&
277
+ session.meta?.origin !== 'subagent' &&
278
+ !sessionIdStr.startsWith('kanban-') &&
279
+ !sessionIdStr.startsWith('kanban_')
280
+ ) {
281
+ let existingTask = store?.findTaskBySession?.(sessionIdStr)
282
+ if (!existingTask && (event?.type === 'turn/start' || event?.type === 'session/created' || event?.type === 'input/user' || event?.type === 'message')) {
283
+ const userText = (event?.content?.[0]?.text ?? event?.text ?? '').trim()
284
+ const sessionTitle = session.title || session.name || (userText ? userText.slice(0, 50) : `Диалог ${sessionIdStr.slice(0, 8)}`)
285
+ const createdTask = store.createTask({
286
+ title: sessionTitle,
287
+ body: userText || `Автоматически создано из сессии чата ${sessionIdStr}`,
288
+ column: 'in-progress',
289
+ board: 'main',
290
+ sessionId: sessionIdStr,
291
+ labels: ['chat-sync'],
292
+ })
293
+ if (createdTask) {
294
+ existingTask = createdTask
295
+ eventHub.broadcast('task:change', { taskId: createdTask.id, action: 'created' })
296
+ }
297
+ } else if (existingTask) {
298
+ if (event?.type === 'turn/end' || event?.type === 'turn/completed') {
299
+ if (existingTask.column === 'in-progress') {
300
+ store.moveTask(existingTask.id, { column: 'review' })
301
+ eventHub.broadcast('task:change', { taskId: existingTask.id, action: 'moved', toCol: 'review' })
302
+ }
303
+ } else if (event?.type === 'turn/error' || event?.type === 'session/error') {
304
+ if (existingTask.column === 'in-progress') {
305
+ store.moveTask(existingTask.id, { column: 'todo' })
306
+ eventHub.broadcast('task:change', { taskId: existingTask.id, action: 'moved', toCol: 'todo' })
307
+ }
308
+ }
309
+ }
310
+ }
311
+
267
312
  const text = event?.content?.[0]?.text ?? event?.text ?? ''
268
313
  const res = handleSessionEvent({
269
314
  store,
@@ -741,6 +786,22 @@ export function apply(ctx, config) {
741
786
  },
742
787
  }), 'dsh-kanban: экспорт в CSV')
743
788
 
789
+ ctx.effect(() => ctx.webServer.register({
790
+ kind: 'exact',
791
+ path: '/dsh-kanban/events',
792
+ handler: (req, res) => {
793
+ eventHub.handleSseRequest(req, res)
794
+ },
795
+ }), 'dsh-kanban: поток живых событий SSE')
796
+
797
+ ctx.effect(() => ctx.webServer.register({
798
+ kind: 'exact',
799
+ path: '/dsh-kanban/templates',
800
+ handler: (req, res) => {
801
+ reply(res, { templates: TASK_TEMPLATES })
802
+ },
803
+ }), 'dsh-kanban: шаблоны задач')
804
+
744
805
  ctx.effect(() => ctx.webServer.register({
745
806
  kind: 'exact',
746
807
  path: '/dsh-kanban/snapshot',
@@ -873,6 +934,7 @@ export function apply(ctx, config) {
873
934
  detail: acted === undefined ? '' : (MOVE_DETAIL[acted] ?? ''),
874
935
  })
875
936
  if (out?.task !== undefined) {
937
+ eventHub.broadcast('task:change', { taskId: id, action: 'moved', toCol: out.task.column })
876
938
  queueOutbound(id, out.task.column)
877
939
  if (acted !== undefined) out.command = acted
878
940
  if (input.column === 'cleanup' && was?.worktree) {
@@ -0,0 +1,65 @@
1
+ // Шаблоны задач для быстрой постановки типовых работ (#220).
2
+
3
+ export const TASK_TEMPLATES = [
4
+ {
5
+ id: 'feature',
6
+ title: 'Новая функциональность (Feature)',
7
+ promptTemplate: 'Реализовать новую функциональность:\n- Цель:\n- Требования:\n- Границы изменений:',
8
+ checklist: [
9
+ { id: '1', text: 'Архитектурный план и дизайн-контракт согласованы', required: true, completed: false },
10
+ { id: '2', text: 'Код написан с соблюдением стандартов проекта', required: true, completed: false },
11
+ { id: '3', text: 'Написаны автоматические модульные/интеграционные тесты', required: true, completed: false },
12
+ { id: '4', text: 'Документация обновлена (README, docs/)', required: true, completed: false },
13
+ ],
14
+ priority: 'medium',
15
+ },
16
+ {
17
+ id: 'bugfix',
18
+ title: 'Исправление ошибки (Bugfix)',
19
+ promptTemplate: 'Локализовать и исправить дефект:\n- Шаги воспроизведения:\n- Ожидаемое поведение:\n- Фактическое поведение:',
20
+ checklist: [
21
+ { id: '1', text: 'Дефект воспроизведен изолированным тестом', required: true, completed: false },
22
+ { id: '2', text: 'Причина устранена без побочных эффектов', required: true, completed: false },
23
+ { id: '3', text: 'Регрессионный тест добавлен в test suite', required: true, completed: false },
24
+ ],
25
+ priority: 'high',
26
+ },
27
+ {
28
+ id: 'refactor',
29
+ title: 'Рефакторинг и оптимизация (Refactoring)',
30
+ promptTemplate: 'Провести рефакторинг модуля без изменения внешнего поведения:\n- Целевой модуль:\n- Проблема архитектуры:\n- Критерии завершения:',
31
+ checklist: [
32
+ { id: '1', text: 'Покрыто существующими тестами (100% green)', required: true, completed: false },
33
+ { id: '2', text: 'Упрощена структура/устранен техдолг', required: true, completed: false },
34
+ { id: '3', text: 'Производительность не деградировала', required: true, completed: false },
35
+ ],
36
+ priority: 'low',
37
+ },
38
+ {
39
+ id: 'security',
40
+ title: 'Аудит безопасности и зависимостей (Security)',
41
+ promptTemplate: 'Проверить безопасность и уязвимости зависимостей:\n- Область проверки:\n- Ревизия зависимостей:',
42
+ checklist: [
43
+ { id: '1', text: 'Проверен supply-chain и лицензии зависимостей', required: true, completed: false },
44
+ { id: '2', text: 'Устранены известные CVE', required: true, completed: false },
45
+ { id: '3', text: 'Секреты и токены проверены на отсутствие утечек', required: true, completed: false },
46
+ ],
47
+ priority: 'high',
48
+ },
49
+ {
50
+ id: 'release',
51
+ title: 'Подготовка и выпуск релиза (Release Prep)',
52
+ promptTemplate: 'Подготовить релиз пакета:\n- Версия:\n- Список изменений:\n- Проверки перед публикацией:',
53
+ checklist: [
54
+ { id: '1', text: 'Все тесты репозитория пройдены (npm test)', required: true, completed: false },
55
+ { id: '2', text: 'Совместимость с ядром подтверждена (npm run compat)', required: true, completed: false },
56
+ { id: '3', text: 'Размер упакованного пакета проверен (< 256 КБ)', required: true, completed: false },
57
+ { id: '4', text: 'Документация и changelog дополнены', required: true, completed: false },
58
+ ],
59
+ priority: 'medium',
60
+ },
61
+ ]
62
+
63
+ export function getTemplateById(id) {
64
+ return TASK_TEMPLATES.find((t) => t.id === id)
65
+ }
package/lib/worktree.js CHANGED
@@ -13,7 +13,8 @@
13
13
 
14
14
  import { execFile } from 'node:child_process'
15
15
  import { promisify } from 'node:util'
16
- import { existsSync, readFileSync } from 'node:fs'
16
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
17
+ import { basename, relative } from 'node:path'
17
18
  import { isAbsolute, join, resolve, sep } from 'node:path'
18
19
 
19
20
  const execFileAsync = promisify(execFile)
@@ -526,3 +527,98 @@ export async function mergeTaskWorktree({
526
527
 
527
528
  return { success: true, merged: true, targetBranch: target }
528
529
  }
530
+
531
+
532
+ /**
533
+ * Рекурсивное обнаружение всех git-репозиториев в каталоге воркспейса (#210).
534
+ *
535
+ * @param {string} rootDir Корневой каталог для поиска
536
+ * @param {{maxDepth?: number, maxRepos?: number, gitRunner?: Function}} options
537
+ * @returns {Promise<Array<{relPath: string, fullPath: string, name: string}>>}
538
+ */
539
+ export async function findWorkspaceGitRepos(rootDir, options = {}) {
540
+ const maxDepth = options.maxDepth ?? 3
541
+ const maxRepos = options.maxRepos ?? 20
542
+ const gitRunner = options.gitRunner ?? defaultGitRunner
543
+
544
+ if (!rootDir || !existsSync(rootDir)) return []
545
+
546
+ const results = []
547
+
548
+ async function scan(dir, depth) {
549
+ if (depth > maxDepth || results.length >= maxRepos) return
550
+ try {
551
+ const isRepo = await isGitRepo(dir, gitRunner)
552
+ if (isRepo) {
553
+ const rel = relative(rootDir, dir) || '.'
554
+ results.push({
555
+ relPath: rel,
556
+ fullPath: resolve(dir),
557
+ name: basename(resolve(dir)),
558
+ })
559
+ // Если это репозиторий (не root), глубже в него обычно не сканируем (кроме субмодулей если нужно)
560
+ if (depth > 0) return
561
+ }
562
+
563
+ const entries = readdirSync(dir, { withFileTypes: true })
564
+ for (const entry of entries) {
565
+ if (!entry.isDirectory()) continue
566
+ if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.worktrees') continue
567
+ await scan(join(dir, entry.name), depth + 1)
568
+ }
569
+ } catch {
570
+ // Игнорируем недоступные директории
571
+ }
572
+ }
573
+
574
+ await scan(rootDir, 0)
575
+ return results
576
+ }
577
+
578
+ /**
579
+ * Подготовка мультирепозиторного зеркалирования для воркспейса с несколькими git-репозиториями (#210).
580
+ *
581
+ * @param {{task: object, workspacePath: string, gitRunner?: Function, config?: object}} options
582
+ * @returns {Promise<{mirrors: Array<{repo: string, worktreePath: string, branch: string}>, errors?: Array<string>}>}
583
+ */
584
+ export async function prepareMultiRepoMirror({ task, workspacePath, gitRunner = defaultGitRunner, config }) {
585
+ if (!workspacePath || !existsSync(workspacePath)) {
586
+ return { mirrors: [], errors: ['workspace-path-not-found'] }
587
+ }
588
+
589
+ const repos = await findWorkspaceGitRepos(workspacePath, { gitRunner })
590
+ const mirrors = []
591
+ const errors = []
592
+ const branchName = resolveTaskBranch(task)
593
+
594
+ for (const repo of repos) {
595
+ try {
596
+ const mirrorRoot = resolve(workspacePath, '.dsh-worktrees', task.id, repo.relPath === '.' ? 'root' : repo.relPath)
597
+ const existing = await listWorktrees(repo.fullPath, gitRunner)
598
+ if (existing.includes(mirrorRoot)) {
599
+ mirrors.push({ repo: repo.name, worktreePath: mirrorRoot, branch: branchName, reused: true })
600
+ continue
601
+ }
602
+
603
+ let branchExists = false
604
+ try {
605
+ await gitRunner(['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`], repo.fullPath)
606
+ branchExists = true
607
+ } catch {
608
+ branchExists = false
609
+ }
610
+
611
+ if (branchExists) {
612
+ await gitRunner(['worktree', 'add', mirrorRoot, branchName], repo.fullPath)
613
+ } else {
614
+ const baseRef = await determineBaseRef(repo.fullPath, gitRunner)
615
+ await gitRunner(['worktree', 'add', '-b', branchName, mirrorRoot, baseRef], repo.fullPath)
616
+ }
617
+ mirrors.push({ repo: repo.name, worktreePath: mirrorRoot, branch: branchName, created: true })
618
+ } catch (err) {
619
+ errors.push(`${repo.name}: ${err?.message || 'failed'}`)
620
+ }
621
+ }
622
+
623
+ return { mirrors, errors: errors.length > 0 ? errors : undefined }
624
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.1.35",
3
+ "version": "0.2.0",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",