@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/worktree.js CHANGED
@@ -1,15 +1,15 @@
1
- // Управляемые воркдеревья для сессий агентов под задачи (Issue #193).
1
+ // Managed worktrees for task agent sessions (Issue #193).
2
2
  //
3
- // Изоляция сессии агента в отдельном git worktree защищает основной
4
- // репозиторий от параллельных правок нескольких задач, предотвращает
5
- // перезапись веток и фиксирует рабочее окружение карточки.
3
+ // Isolating agent sessions in git worktrees protects the main repository
4
+ // from concurrent modifications, prevents branch overwrite conflicts,
5
+ // and scopes working directory per task.
6
6
  //
7
- // Жизненный цикл:
8
- // 1. При переходе в In Progress создаётся воркдерево task/<id>-<slug>
9
- // 2. Воркдерево регистрируется в DSH workspaceRegistry как рабочий каталог
10
- // 3. Сессия агента запускается с cwd = worktreePath
11
- // 4. В колонке Cleanup проверяются незакоммиченные файлы (dirtyFiles)
12
- // 5. После подтверждения воркдерево удаляется, воркспейс дерегистрируется.
7
+ // Lifecycle:
8
+ // 1. On transition to In Progress: create worktree task/<id>-<slug>
9
+ // 2. Register worktree in DSH workspaceRegistry as active workspace
10
+ // 3. Launch agent session with cwd = worktreePath
11
+ // 4. In Cleanup column: verify uncommitted files (dirtyFiles)
12
+ // 5. Upon confirmation: remove worktree and deregister workspace.
13
13
 
14
14
  import { execFile } from 'node:child_process'
15
15
  import { promisify } from 'node:util'
@@ -20,8 +20,8 @@ import { isAbsolute, join, resolve, sep } from 'node:path'
20
20
  const execFileAsync = promisify(execFile)
21
21
 
22
22
  /**
23
- * Стандартный исполнитель git-команд.
24
- * Принимает аргументы массивом, исключая шелл-инъекции.
23
+ * Standard git command runner.
24
+ * Accepts argument array to eliminate shell injection vulnerabilities.
25
25
  */
26
26
  export async function defaultGitRunner(args, cwd) {
27
27
  try {
@@ -41,7 +41,7 @@ export async function defaultGitRunner(args, cwd) {
41
41
  }
42
42
 
43
43
  /**
44
- * Преобразовать строку в безопасный фрагмент имени ветки или каталога.
44
+ * Convert string into safe branch/directory slug.
45
45
  */
46
46
  export function slugify(text) {
47
47
  if (typeof text !== 'string') return 'task'
@@ -49,7 +49,7 @@ export function slugify(text) {
49
49
  .toLowerCase()
50
50
  .normalize('NFD')
51
51
  .replace(/[\u0300-\u036f]/g, '')
52
- .replace(/[^a-z0-9а-яё_-]+/gi, '-')
52
+ .replace(/[^\p{Letter}0-9_-]+/gu, '-')
53
53
  .replace(/-+/g, '-')
54
54
  .replace(/^-|-$/g, '')
55
55
  .slice(0, 40)
@@ -57,7 +57,7 @@ export function slugify(text) {
57
57
  }
58
58
 
59
59
  /**
60
- * Безопасный ключ репозитория для каталога воркдеревьев.
60
+ * Safe repository key for worktree root directory.
61
61
  */
62
62
  export function safeRepoKey(task) {
63
63
  const owner = typeof task?.owner === 'string' ? task.owner.trim() : ''
@@ -68,7 +68,7 @@ export function safeRepoKey(task) {
68
68
  }
69
69
 
70
70
  /**
71
- * Определить корневой каталог для всех управляемых воркдеревьев.
71
+ * Resolve root directory for all managed worktrees.
72
72
  */
73
73
  export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => process.cwd()) {
74
74
  const custom = typeof config?.worktreeRoot === 'string' ? config.worktreeRoot.trim() : ''
@@ -85,8 +85,24 @@ export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => pro
85
85
  }
86
86
 
87
87
  /**
88
- * Вычислить канонический путь к воркдереву задачи с защитой от path traversal.
88
+ * Resolve canonical worktree path for task with path traversal protection.
89
89
  */
90
+ export function isSafeBranchName(name) {
91
+ if (typeof name !== 'string') return false
92
+ const trimmed = name.trim()
93
+ if (trimmed === '' || trimmed.startsWith('-') || trimmed.startsWith('/') || trimmed.endsWith('/')) return false
94
+ if (trimmed.includes('..') || trimmed.includes('~') || trimmed.includes('^') || trimmed.includes(':') || trimmed.includes('?') || trimmed.includes('*') || trimmed.includes('[') || trimmed.includes('\\') || trimmed.includes('@{') || trimmed.endsWith('.lock')) return false
95
+ return /^[A-Za-z0-9_./-]+$/.test(trimmed)
96
+ }
97
+
98
+ export function isPathFenced(targetPath, rootPath) {
99
+ if (!targetPath || !rootPath) return false
100
+ const resolvedTarget = resolve(targetPath)
101
+ const resolvedRoot = resolve(rootPath)
102
+ const fence = resolvedRoot.endsWith(sep) || resolvedRoot.endsWith('/') ? resolvedRoot : resolvedRoot + sep
103
+ return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(fence)
104
+ }
105
+
90
106
  export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
91
107
  const root = resolveWorktreeRoot(config, env, cwdOf)
92
108
  const repoKey = safeRepoKey(task)
@@ -95,13 +111,13 @@ export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
95
111
 
96
112
  const fenceSep = root.endsWith(sep) || root.endsWith('/') ? root : root + sep
97
113
  if (target !== root && !target.startsWith(fenceSep) && !target.startsWith(root + '/')) {
98
- throw new Error('путь воркдерева выходит за пределы корня воркдеревьев')
114
+ throw new Error('worktree path escapes worktree root')
99
115
  }
100
116
  return target
101
117
  }
102
118
 
103
119
  /**
104
- * Сформировать имя ветки задачи.
120
+ * Generate task branch name.
105
121
  */
106
122
  export function resolveTaskBranch(task) {
107
123
  const prefix = typeof task?.issueNumber === 'number'
@@ -112,7 +128,7 @@ export function resolveTaskBranch(task) {
112
128
  }
113
129
 
114
130
  /**
115
- * Проверить, является ли каталог git-репозиторием.
131
+ * Check if directory is a valid git repository.
116
132
  */
117
133
  export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
118
134
  if (!repoDir) return false
@@ -125,7 +141,7 @@ export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
125
141
  }
126
142
 
127
143
  /**
128
- * Получить список существующих путей воркдеревьев для репозитория.
144
+ * List existing worktree paths for repository.
129
145
  */
130
146
  export async function listWorktrees(repoDir, gitRunner = defaultGitRunner) {
131
147
  try {
@@ -143,7 +159,7 @@ export async function listWorktrees(repoDir, gitRunner = defaultGitRunner) {
143
159
  }
144
160
 
145
161
  /**
146
- * Определить базовую ветку для создания воркдерева.
162
+ * Resolve base ref for worktree creation.
147
163
  */
148
164
  export async function determineBaseRef(repoDir, gitRunner = defaultGitRunner) {
149
165
  for (const candidate of ['origin/main', 'origin/master', 'main', 'master', 'HEAD']) {
@@ -158,7 +174,7 @@ export async function determineBaseRef(repoDir, gitRunner = defaultGitRunner) {
158
174
  }
159
175
 
160
176
  /**
161
- * Создать или переиспользовать воркдерево под задачу.
177
+ * Create or reuse worktree for task.
162
178
  */
163
179
  export async function createTaskWorktree({
164
180
  task,
@@ -180,13 +196,13 @@ export async function createTaskWorktree({
180
196
  const worktreePath = resolveTaskWorktreePath({ task, config, env, cwdOf })
181
197
  const branchName = resolveTaskBranch(task)
182
198
 
183
- // Проверяем, не зарегистрировано ли уже это воркдерево
199
+ // Check if worktree is already registered
184
200
  const existing = await listWorktrees(repoDir, gitRunner)
185
201
  if (existing.includes(resolve(worktreePath))) {
186
202
  return { worktreePath, branchName, reused: true }
187
203
  }
188
204
 
189
- // Проверяем, существует ли уже локальная ветка с таким именем
205
+ // Check if local branch already exists
190
206
  let branchExists = false
191
207
  try {
192
208
  await gitRunner(['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`], repoDir)
@@ -206,7 +222,7 @@ export async function createTaskWorktree({
206
222
  }
207
223
 
208
224
  /**
209
- * Проверить наличие незакоммиченных изменений в воркдереве.
225
+ * Check for uncommitted changes in worktree.
210
226
  */
211
227
  function isSubmoduleDrift(worktreePath, line) {
212
228
  try {
@@ -226,14 +242,14 @@ function isSubmoduleDrift(worktreePath, line) {
226
242
  }
227
243
  }
228
244
  } catch {
229
- // Игнорируем ошибки доступа к файлам при проверке
245
+ // Ignore file access errors during check
230
246
  }
231
247
  return false
232
248
  }
233
249
 
234
250
  /**
235
- * Проверить наличие незакоммиченных изменений в воркдереве.
236
- * Исключает структурный шум субмодулей и дрейф gitlinks (#213).
251
+ * Check for uncommitted changes in worktree.
252
+ * Excludes submodule noise and gitlink drift (#213).
237
253
  */
238
254
  export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitRunner }) {
239
255
  if (!worktreePath || !existsSync(worktreePath)) {
@@ -253,7 +269,7 @@ export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitR
253
269
  }
254
270
 
255
271
  /**
256
- * Безопасно удалить воркдерево задачи.
272
+ * Safely remove task worktree.
257
273
  */
258
274
  export async function removeTaskWorktree({
259
275
  repoDir,
@@ -262,11 +278,16 @@ export async function removeTaskWorktree({
262
278
  force = false,
263
279
  deleteBranch = true,
264
280
  gitRunner = defaultGitRunner,
281
+ rootPath,
265
282
  }) {
266
283
  if (!worktreePath || !existsSync(worktreePath)) {
267
284
  return { removed: false, reason: 'not-found' }
268
285
  }
269
286
 
287
+ if (rootPath && !isPathFenced(worktreePath, rootPath)) {
288
+ return { removed: false, error: 'unfenced-worktree-path' }
289
+ }
290
+
270
291
  const dirtyCheck = await checkWorktreeDirty({ worktreePath, gitRunner })
271
292
  if (dirtyCheck.dirty && !force) {
272
293
  return {
@@ -281,7 +302,7 @@ export async function removeTaskWorktree({
281
302
  : ['worktree', 'remove', worktreePath]
282
303
 
283
304
  await gitRunner(removeArgs, repoDir)
284
- try { await gitRunner(['worktree', 'prune'], repoDir) } catch { /* prune игнорирует ошибки */ }
305
+ try { await gitRunner(['worktree', 'prune'], repoDir) } catch { /* prune ignores errors */ }
285
306
 
286
307
  let branchDeleted = false
287
308
  if (deleteBranch && branchName && repoDir) {
@@ -289,7 +310,7 @@ export async function removeTaskWorktree({
289
310
  await gitRunner(['branch', '-d', branchName], repoDir)
290
311
  branchDeleted = true
291
312
  } catch {
292
- // Игнорируем: ветка не удалена, если ещё не влита
313
+ // Ignored: branch not deleted if not yet merged
293
314
  branchDeleted = false
294
315
  }
295
316
  }
@@ -298,7 +319,7 @@ export async function removeTaskWorktree({
298
319
  }
299
320
 
300
321
  /**
301
- * Зарегистрировать каталог воркдерева в DSH workspaceRegistry.
322
+ * Register worktree path in DSH workspaceRegistry.
302
323
  */
303
324
  export async function registerDshWorkspace({ workspaceRegistry, worktreePath, title }) {
304
325
  if (!workspaceRegistry || typeof workspaceRegistry.create !== 'function') return undefined
@@ -311,7 +332,7 @@ export async function registerDshWorkspace({ workspaceRegistry, worktreePath, ti
311
332
  }
312
333
 
313
334
  /**
314
- * Дерегистрировать воркспейс из DSH workspaceRegistry.
335
+ * Deregister workspace from DSH workspaceRegistry.
315
336
  */
316
337
  export async function deregisterDshWorkspace({ workspaceRegistry, workspaceId }) {
317
338
  if (!workspaceRegistry || typeof workspaceRegistry.delete !== 'function' || !workspaceId) return false
@@ -323,7 +344,7 @@ export async function deregisterDshWorkspace({ workspaceRegistry, workspaceId })
323
344
  }
324
345
 
325
346
  /**
326
- * Выполнить очистку воркдерева задачи с проверкой незакоммиченных изменений.
347
+ * Clean up task worktree with dirty files guard.
327
348
  */
328
349
  export async function cleanupTaskWorktree({
329
350
  store,
@@ -333,11 +354,26 @@ export async function cleanupTaskWorktree({
333
354
  force = false,
334
355
  deleteBranch = true,
335
356
  gitRunner = defaultGitRunner,
357
+ config,
358
+ env,
359
+ cwdOf,
336
360
  }) {
337
361
  const current = typeof task === 'object' && task !== null ? task : store?.getTask?.(task)
338
362
  if (!current) return { error: 'task-not-found', status: 404 }
339
363
  if (!current.worktree) return { noop: true, removed: false }
340
364
 
365
+ const root = resolveWorktreeRoot(config, env, cwdOf)
366
+ const candidateRoots = [
367
+ root,
368
+ repoDir,
369
+ typeof cwdOf === 'function' ? cwdOf() : process.cwd(),
370
+ process.cwd(),
371
+ ].filter(Boolean)
372
+ const isFenced = candidateRoots.some((c) => isPathFenced(current.worktree, c))
373
+ if (!isFenced) {
374
+ return { error: 'unfenced-worktree-path', status: 403 }
375
+ }
376
+
341
377
  const targetRepoDir = repoDir || current.worktree
342
378
  const result = await removeTaskWorktree({
343
379
  repoDir: targetRepoDir,
@@ -354,7 +390,7 @@ export async function cleanupTaskWorktree({
354
390
  fromCol: current.column,
355
391
  toCol: current.column,
356
392
  source: 'session',
357
- detail: `очистка отложена: обнаружены незакоммиченные файлы: ${result.files.slice(0, 3).join(', ')}`,
393
+ detail: `cleanup deferred: uncommitted files detected: ${result.files.slice(0, 3).join(', ')}`,
358
394
  })
359
395
  return { error: 'worktree-dirty', files: result.files }
360
396
  }
@@ -369,14 +405,14 @@ export async function cleanupTaskWorktree({
369
405
  fromCol: current.column,
370
406
  toCol: current.column,
371
407
  source: 'session',
372
- detail: 'очистка: воркдерево удалено' + (result.branchDeleted ? ', ветка удалена' : ''),
408
+ detail: 'cleanup: worktree removed' + (result.branchDeleted ? ', branch deleted' : ''),
373
409
  })
374
410
 
375
411
  return { success: true, removed: true, branchDeleted: result.branchDeleted }
376
412
  }
377
413
 
378
414
  /**
379
- * Получить unified diff изменений в воркдереве задачи (#211).
415
+ * Generate unified diff of changes in task worktree (#211).
380
416
  *
381
417
  * @param {{worktreePath: string, baseRef?: string, gitRunner?: Function}} options
382
418
  * @returns {Promise<{diff: string, truncated: boolean, filesChanged: number}>}
@@ -391,7 +427,7 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
391
427
  base = await determineBaseRef(worktreePath, gitRunner)
392
428
  }
393
429
 
394
- // Сначала пробуем git diff base...HEAD, если base доступен
430
+ // Try git diff base...HEAD first if base ref is available
395
431
  let diffOut = ''
396
432
  if (base) {
397
433
  const res = await gitRunner(['diff', `${base}...HEAD`], worktreePath)
@@ -400,23 +436,23 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
400
436
  }
401
437
  }
402
438
 
403
- // Дополнительно смотрим незакоммиченные изменения (unstaged + staged)
439
+ // Include uncommitted changes (unstaged + staged)
404
440
  const unstaged = await gitRunner(['diff', 'HEAD'], worktreePath)
405
441
  if (unstaged.exitCode === 0 && unstaged.stdout.trim() !== '') {
406
442
  diffOut = diffOut ? `${diffOut}\n\n# Uncommitted Changes:\n${unstaged.stdout}` : unstaged.stdout
407
443
  }
408
444
 
409
- // Если и base...HEAD, и HEAD ничего не дали, берём чистый git diff
445
+ // Fallback to plain git diff if base...HEAD produced no output
410
446
  if (!diffOut.trim()) {
411
447
  const fallback = await gitRunner(['diff'], worktreePath)
412
448
  if (fallback.exitCode === 0) diffOut = fallback.stdout
413
449
  }
414
450
 
415
- // Подсчитываем число затронутых файлов
451
+ // Count affected files
416
452
  const fileLines = diffOut.split('\n').filter((l) => l.startsWith('diff --git'))
417
453
  const filesChanged = fileLines.length
418
454
 
419
- // Защита от переполнения: обрезаем diff до 250 КБ
455
+ // Memory guard: cap diff output at 250 KiB
420
456
  const MAX_DIFF_BYTES = 250 * 1024
421
457
  let truncated = false
422
458
  if (Buffer.byteLength(diffOut, 'utf8') > MAX_DIFF_BYTES) {
@@ -428,7 +464,7 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
428
464
  }
429
465
 
430
466
  /**
431
- * Получить список коммитов ветки задачи относительно базовой ветки (#211).
467
+ * List task branch commits relative to base branch (#211).
432
468
  *
433
469
  * @param {{worktreePath: string, baseRef?: string, gitRunner?: Function}} options
434
470
  * @returns {Promise<Array<{hash: string, subject: string, author: string, date: string}>>}
@@ -446,7 +482,7 @@ export async function getTaskCommits({ worktreePath, baseRef, gitRunner = defaul
446
482
  const range = base ? `${base}..HEAD` : '-n 20'
447
483
  const logRes = await gitRunner(['log', range, '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
448
484
  if (logRes.exitCode !== 0 || !logRes.stdout.trim()) {
449
- // fallback на последние 10 коммитов
485
+ // Fallback to last 10 commits
450
486
  const fallback = await gitRunner(['log', '-n', '10', '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
451
487
  if (fallback.exitCode !== 0 || !fallback.stdout.trim()) return []
452
488
  return fallback.stdout.trim().split('\n').map(parseCommitLine).filter(Boolean)
@@ -467,7 +503,7 @@ function parseCommitLine(line) {
467
503
  }
468
504
 
469
505
  /**
470
- * Слияние ветки воркдерева в основную ветку репозитория (--no-ff) при приёмке (#212).
506
+ * Merge worktree branch into repository main branch (--no-ff) upon acceptance (#212).
471
507
  *
472
508
  * @param {{task: object, targetBranch?: string, gitRunner?: Function, workspaceRegistry?: object}} options
473
509
  * @returns {Promise<{success: boolean, merged: boolean, error?: string}>}
@@ -479,13 +515,17 @@ export async function mergeTaskWorktree({
479
515
  return { success: false, merged: false, error: 'no-worktree-or-branch' }
480
516
  }
481
517
 
482
- // 1. Проверяем чистоту воркдерева
518
+ if (targetBranch && !isSafeBranchName(targetBranch)) {
519
+ return { success: false, merged: false, error: 'invalid-target-branch' }
520
+ }
521
+
522
+ // 1. Verify worktree cleanliness
483
523
  const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree, gitRunner })
484
524
  if (dirtyCheck.dirty) {
485
525
  return { success: false, merged: false, error: 'worktree-dirty', files: dirtyCheck.files }
486
526
  }
487
527
 
488
- // 2. Находим корень основного репозитория (из .git файла воркдерева или config)
528
+ // 2. Locate main repository root
489
529
  let mainRepoDir = ''
490
530
  const revParse = await gitRunner(['rev-parse', '--git-common-dir'], task.worktree)
491
531
  if (revParse.exitCode === 0 && revParse.stdout.trim()) {
@@ -497,32 +537,35 @@ export async function mergeTaskWorktree({
497
537
  return { success: false, merged: false, error: 'main-repo-not-found' }
498
538
  }
499
539
 
500
- // 3. Определяем целевую ветку для слияния
540
+ // 3. Resolve target merge branch
501
541
  let target = targetBranch
542
+ if (target && !isSafeBranchName(target)) {
543
+ return { success: false, merged: false, error: 'invalid-target-branch' }
544
+ }
502
545
  if (!target) {
503
546
  target = await determineBaseRef(mainRepoDir, gitRunner)
504
547
  }
505
- if (!target) target = 'main'
548
+ if (!target || !isSafeBranchName(target)) target = 'main'
506
549
 
507
- // 4. Переключаемся или проверяем, что в целевой ветке в основном репо
550
+ // 4. Switch or verify target branch in main repository
508
551
  const currentBranch = await gitRunner(['branch', '--show-current'], mainRepoDir)
509
552
  if (currentBranch.stdout.trim() !== target) {
510
- const checkoutRes = await gitRunner(['checkout', target], mainRepoDir)
553
+ const checkoutRes = await gitRunner(['checkout', '--', target], mainRepoDir)
511
554
  if (checkoutRes.exitCode !== 0) {
512
555
  return { success: false, merged: false, error: 'checkout-target-failed', detail: checkoutRes.stderr }
513
556
  }
514
557
  }
515
558
 
516
- // 5. Выполняем git merge --no-ff
559
+ // 5. Execute git merge --no-ff
517
560
  const mergeMsg = `Merge branch '${task.branch}' for task ${task.id}`
518
561
  const mergeRes = await gitRunner(['merge', '--no-ff', task.branch, '-m', mergeMsg], mainRepoDir)
519
562
  if (mergeRes.exitCode !== 0) {
520
- // В случае конфликта откатываем мерж
563
+ // Roll back merge on conflict
521
564
  await gitRunner(['merge', '--abort'], mainRepoDir)
522
565
  return { success: false, merged: false, error: 'merge-conflict', detail: mergeRes.stderr || mergeRes.stdout }
523
566
  }
524
567
 
525
- // 6. Очищаем воркдерево задачи
568
+ // 6. Clean up task worktree
526
569
  await cleanupTaskWorktree({
527
570
  task,
528
571
  workspaceRegistry,
@@ -535,9 +578,9 @@ export async function mergeTaskWorktree({
535
578
 
536
579
 
537
580
  /**
538
- * Рекурсивное обнаружение всех git-репозиториев в каталоге воркспейса (#210).
581
+ * Recursive discovery of git repositories within workspace directory (#210).
539
582
  *
540
- * @param {string} rootDir Корневой каталог для поиска
583
+ * @param {string} rootDir Root directory to scan
541
584
  * @param {{maxDepth?: number, maxRepos?: number, gitRunner?: Function}} options
542
585
  * @returns {Promise<Array<{relPath: string, fullPath: string, name: string}>>}
543
586
  */
@@ -561,7 +604,7 @@ export async function findWorkspaceGitRepos(rootDir, options = {}) {
561
604
  fullPath: resolve(dir),
562
605
  name: basename(resolve(dir)),
563
606
  })
564
- // Если это репозиторий (не root), глубже в него обычно не сканируем (кроме субмодулей если нужно)
607
+ // Do not recurse into nested non-root repositories
565
608
  if (depth > 0) return
566
609
  }
567
610
 
@@ -572,7 +615,7 @@ export async function findWorkspaceGitRepos(rootDir, options = {}) {
572
615
  await scan(join(dir, entry.name), depth + 1)
573
616
  }
574
617
  } catch {
575
- // Игнорируем недоступные директории
618
+ // Ignore inaccessible directories
576
619
  }
577
620
  }
578
621
 
@@ -581,7 +624,7 @@ export async function findWorkspaceGitRepos(rootDir, options = {}) {
581
624
  }
582
625
 
583
626
  /**
584
- * Подготовка мультирепозиторного зеркалирования для воркспейса с несколькими git-репозиториями (#210).
627
+ * Multi-repository mirror setup for workspaces with multiple git repositories (#210).
585
628
  *
586
629
  * @param {{task: object, workspacePath: string, gitRunner?: Function, config?: object}} options
587
630
  * @returns {Promise<{mirrors: Array<{repo: string, worktreePath: string, branch: string}>, errors?: Array<string>}>}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",