@goodandready/dsh-kanban 0.1.23 → 0.1.25

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/README.md CHANGED
@@ -260,6 +260,21 @@ compares the requested names against the actual exports. It is deliberately not
260
260
  part of `npm test`: the test suite runs without a core and without a profile.
261
261
  Run it on the machine where the harness lives, before publishing.
262
262
 
263
+ ## 💻 Cross-Platform Path Handling
264
+
265
+ Task working directories (`resolveCwd`) respect operating system path conventions.
266
+ On Windows, repository root fencing accounts for drive letters and backslash path
267
+ separators, ensuring full test and runtime compatibility across POSIX and Windows.
268
+
269
+ ## 🌳 Git Worktree Isolation
270
+
271
+ When in-progress work begins on a repository task, the agent session is automatically
272
+ isolated into a dedicated git worktree (`$DSH_HOME/worktrees/<repo-key>/<task-id>/` on branch
273
+ `task/<id>-<slug>`). The worktree is registered in DSH's `workspaceRegistry` for full UI
274
+ visibility and multi-agent concurrency protection. During the `cleanup` phase, uncommitted files
275
+ are detected via `git status --porcelain` to prevent data loss, followed by clean worktree and workspace
276
+ deregistration.
277
+
263
278
  ## 📄 License
264
279
 
265
280
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
@@ -0,0 +1,53 @@
1
+ # DESIGN.md — dsh-kanban
2
+
3
+ ## Product / Purpose
4
+ - Назначение: Интегрированная доска задач для DeepSeek Harness с двусторонней сверкой с Gitea, автоматическим подхватом сессий агентов и управлением жизненным циклом.
5
+ - Аудитория: Разработчики и операторы DeepSeek Harness, ведущие проектные задачи и сессии агентов.
6
+ - Статус: Production, версия 0.1.23+.
7
+
8
+ ## User Surfaces
9
+ - Web/UI: Экран доски в центральной колонке интерфейса DSH (mountBoard), выдвигающаяся панель задач, фильтры, группировки (по проектам, по исполнителю), метрики переходов.
10
+ - DSH UI / settings / slots:
11
+ - Карточка настроек в слоте settings.plugin.item (пространство dsh-kanban).
12
+ - Чип активной задачи в conversation.session.header.utilities.
13
+ - Пункт бокового меню для вызова экрана доски.
14
+ - API: HTTP эндпоинты /dsh-kanban/* (board, snapshot, task, sync, webhook, metrics, whoami).
15
+ - CLI: node tools/compat.mjs (проверка совместимости экспортов ядра).
16
+ - Документация: README.md, docs/README.ru.md, docs/README.zh.md, index.md.
17
+
18
+ ## Visual Direction
19
+ - Атмосфера: Строгий утилитарный инструментальный интерфейс в едином стиле темы DeepSeek Harness.
20
+ - Утверждённые референсы и что из них берём: Системные карточки DSH (стили заголовков, отступы, шрифты, цветовые токены --dsw-alias-*).
21
+ - Не копировать: Внешние неинтегрированные темы, разнородные цвета рамок и теней.
22
+
23
+ ## Foundations
24
+ - Цвета и роли: Использование CSS-переменных DSH темы (--dsw-alias-bg-layer-3, --dsw-alias-border-l2, --dsw-alias-label-primary, --dsw-alias-label-secondary).
25
+ - Типографика: Системный стек шрифтов DSH (inherit, 13-14px базовый размер, 11-12px для бейджей и служебных меток).
26
+ - Сетка, отступы, responsive: 5 стандартных колонок (backlog, ready, progress, review, done) с горизонтальным скроллом и адаптивными карточками.
27
+ - Accessibility: Нативные кнопки button, role="status" для сообщений, семантичные поля ввода с aria-label/label.
28
+
29
+ ## Components And States
30
+ - Компоненты: BoardScreen, Column, Card, TaskDrawer, KanbanSettingsCard, TaskChip, MetricsModal.
31
+ - Loading / empty / error / success:
32
+ - Загрузка: состояние скелетона/лоадера без блокировки скролла.
33
+ - Пустые колонки: пунктирная область перетаскивания.
34
+ - Ошибки: всплывающий баннер с ключом локализации t('error.' + err).
35
+ - Формы, валидация и действия: Валидация обязательных полей при создании задачи, блокировка повторных кликов при сохранении.
36
+
37
+ ## User Flows
38
+ - Критические сценарии:
39
+ 1. Создание задачи / подхват из Gitea Issue.
40
+ 2. Перемещение карточки между колонками человеком или через инструмент агента board_move.
41
+ 3. Запуск сессии агента по карточке с автоматическим назначением ответственного.
42
+ 4. Автоматическое обновление статуса (waiting) по событиям ядра (session/event).
43
+ 5. Экспорт и импорт снимка доски в JSON.
44
+
45
+ ## Do / Don't
46
+ - Do: Использовать только токены темы --dsw-alias-*, очищать все слушатели событий при размонтировании, соблюдать порядок хуков React.
47
+ - Don't: Не переводить карточку в done агентом (колонка done фиксируется только по факту закрытия issue и ветки).
48
+
49
+ ## Locked Design Decisions
50
+ - 2026-08-20 — Карточка настроек монтируется в settings.plugin.item с резервом в settings.section.
51
+ - 2026-08-25 — Использование дробной индексации ($O(1)$) для позиционирования карточек.
52
+ - 2026-09-02 — Снимок доски объединён на маршруте /dsh-kanban/snapshot (GET: выгрузка, POST: загрузка).
53
+ - 2026-09-03 — Изоляция сессий задач в git worktree ($DSH_HOME/worktrees/<repo-key>/<task-id>/ на ветке task/<id>-<slug>), регистрация воркдерева в workspaceRegistry, проверка dirtyFiles и безопасная очистка в фазе cleanup (#193).
package/lib/client.js CHANGED
@@ -858,6 +858,10 @@ window.__ModuleLoader__.load({
858
858
  'field.boardToolEnabled': 'Let the agent move cards',
859
859
  'field.pushToGitea': 'Close the issue in Gitea',
860
860
  'field.webhookSecretRef': 'Webhook credential name',
861
+ 'field.worktreeIsolation': 'Isolate tasks in git worktrees',
862
+ 'hint.worktreeIsolation': 'Creates a separate git worktree for each task session to prevent file collisions.',
863
+ 'field.worktreeRoot': 'Worktree root directory',
864
+ 'hint.worktreeRoot': 'Custom folder for task worktrees. Leave empty to use $DSH_HOME/worktrees.',
861
865
  'hint.pushToGitea': 'A card reaching Done closes its issue. The board sets no labels: the stage is already visible from the branch and the pull request.',
862
866
  'hint.webhookSecretRef': 'NAME of a DSH credential holding the Gitea webhook secret. Empty leaves only polling.',
863
867
  'hint.syncIntervalSec': 'Zero turns the check off. Below fifteen seconds is raised to fifteen.',
@@ -1069,6 +1073,9 @@ window.__ModuleLoader__.load({
1069
1073
  'panel.startHint': 'A dedicated agent session opens for this task. Branch and worktree are created by the agent after its preflight, not here.',
1070
1074
  'panel.workDir': 'Working folder: {path}',
1071
1075
  'panel.workDirHint': 'The project root is not set in settings, so this folder is used.',
1076
+ 'panel.worktree': 'Worktree: {path}',
1077
+ 'panel.cleanupWorktree': 'Clean Worktree',
1078
+ 'panel.worktreeDirty': 'Uncommitted changes in worktree: {files}',
1072
1079
  'panel.priority': 'Priority',
1073
1080
  'panel.priorityNone': 'No priority',
1074
1081
  'priority.high': 'High',
@@ -1125,6 +1132,10 @@ window.__ModuleLoader__.load({
1125
1132
  'field.boardToolEnabled': '允许 Agent 移动卡片',
1126
1133
  'field.pushToGitea': '在 Gitea 中关闭 issue',
1127
1134
  'field.webhookSecretRef': 'Webhook 凭据名称',
1135
+ 'field.worktreeIsolation': '在 git worktree 中隔离任务',
1136
+ 'hint.worktreeIsolation': '为每个任务会话创建独立的 git worktree 以防止文件冲突。',
1137
+ 'field.worktreeRoot': 'Worktree 根目录',
1138
+ 'hint.worktreeRoot': '任务 worktree 的自定义目录。留空则使用 $DSH_HOME/worktrees。',
1128
1139
  'hint.pushToGitea': '卡片进入「已完成」即关闭其 issue。看板不设置任何标签:阶段从分支和 pull request 上已经看得出来。',
1129
1140
  'hint.webhookSecretRef': '保存 Gitea webhook 密钥的 DSH 凭据的名称。留空则只剩轮询。',
1130
1141
  'hint.syncIntervalSec': '填 0 关闭核对。小于十五秒会被提到十五秒。',
@@ -1336,6 +1347,9 @@ window.__ModuleLoader__.load({
1336
1347
  'panel.startHint': '会为该任务打开一个专属的 Agent 会话。分支和 worktree 由 Agent 在自检之后创建,不在这里。',
1337
1348
  'panel.workDir': '工作目录:{path}',
1338
1349
  'panel.workDirHint': '设置中未指定项目根目录,因此使用此目录。',
1350
+ 'panel.worktree': '工作区:{path}',
1351
+ 'panel.cleanupWorktree': '清理工作区',
1352
+ 'panel.worktreeDirty': '工作区中存在未提交的更改:{files}',
1339
1353
  'panel.priority': '优先级',
1340
1354
  'panel.priorityNone': '无优先级',
1341
1355
  'priority.high': '高',
@@ -1395,6 +1409,8 @@ window.__ModuleLoader__.load({
1395
1409
  { field: 'boardToolEnabled', kind: 'bool', group: 'sync' },
1396
1410
  { field: 'pushToGitea', kind: 'bool', group: 'sync' },
1397
1411
  { field: 'webhookSecretRef', kind: 'text', group: 'sync' },
1412
+ { field: 'worktreeIsolation', kind: 'bool', group: 'general' },
1413
+ { field: 'worktreeRoot', kind: 'text', group: 'general' },
1398
1414
  ]
1399
1415
 
1400
1416
  function hintKey(spec) {
@@ -2507,6 +2523,11 @@ window.__ModuleLoader__.load({
2507
2523
  target: '_blank', rel: 'noreferrer noopener',
2508
2524
  }, ' · ' + t('panel.issue') + ' ↗')
2509
2525
  : null),
2526
+ openTask.worktree
2527
+ ? React.createElement('div', { className: 'dkb-note', style: { wordBreak: 'break-all' } },
2528
+ t('panel.worktree', { path: openTask.worktree })
2529
+ + (openTask.branch ? ' (' + openTask.branch + ')' : ''))
2530
+ : null,
2510
2531
  // Пустое тело — отсутствие раздела, а не пустой раздел с заголовком.
2511
2532
  body === null
2512
2533
  ? React.createElement('p', { className: 'dkb-note' }, t('panel.bodyLoading'))
@@ -2717,7 +2738,24 @@ window.__ModuleLoader__.load({
2717
2738
  },
2718
2739
  }, t('panel.archive'))
2719
2740
  : null,
2720
-
2741
+ openTask.column === 'cleanup' && openTask.worktree
2742
+ ? React.createElement('button', {
2743
+ type: 'button', className: 'dkb-discard',
2744
+ onClick: async () => {
2745
+ try {
2746
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/cleanup-worktree', {
2747
+ method: 'POST', body: '{}',
2748
+ })
2749
+ if (out && out.error === 'worktree-dirty') {
2750
+ alert(t('panel.worktreeDirty', { files: (out.files || []).join(', ') }))
2751
+ } else {
2752
+ setOpenTask(null)
2753
+ await load(board)
2754
+ }
2755
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2756
+ },
2757
+ }, t('panel.cleanupWorktree'))
2758
+ : null,
2721
2759
  ),
2722
2760
  React.createElement('button', {
2723
2761
  type: 'button', className: 'dkb-save', disabled: starting || model === '',
package/lib/config.js CHANGED
@@ -71,6 +71,8 @@ export const CONFIG_DEFAULTS = {
71
71
  boardToolEnabled: false,
72
72
  webhookSecretRef: '',
73
73
  pushToGitea: true,
74
+ worktreeIsolation: true,
75
+ worktreeRoot: '',
74
76
  }
75
77
 
76
78
  /** Пояснения к полям; повторно используются схемой и карточкой настроек. */
@@ -91,6 +93,8 @@ export const CONFIG_HINTS = {
91
93
  boardToolEnabled: 'Разрешить агенту двигать карточки инструментом board_move. Выключено, пока скилл воркфлоу запрещает CLI трогать канбан.',
92
94
  webhookSecretRef: 'ИМЯ учётной записи DSH с секретом вебхука Gitea. Пусто — вебхук не принимается, остаётся только опрос.',
93
95
  pushToGitea: 'Закрывать issue в Gitea, когда карточка уходит в колонку «Выполнено».',
96
+ worktreeIsolation: 'Изолировать сессии задач в отдельные git worktrees для предотвращения коллизий.',
97
+ worktreeRoot: 'Каталог для воркдеревьев задач. Пусто — $DSH_HOME/worktrees или .worktrees проекта.',
94
98
  }
95
99
 
96
100
  /** Поле настроек с пределом числа карточек; задан не у всех колонок. */
package/lib/index.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  runTask, resolveModel, buildStartMessage,
30
30
  runBatch, queueTask, unqueueTask, liveSessions,
31
31
  } from './launcher.js'
32
+ import { cleanupTaskWorktree, checkWorktreeDirty } from './worktree.js'
32
33
  import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
33
34
  import { createGiteaClient } from './gitea.js'
34
35
  import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
@@ -661,6 +662,7 @@ export function apply(ctx, config) {
661
662
  agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
662
663
  permission: typeof input.permission === 'string' ? input.permission : undefined,
663
664
  permissions: ctx.get('permissionPresets'),
665
+ workspaceRegistry: ctx.get('workspaceRegistry'),
664
666
  claimAs: await whoAmI(),
665
667
  }))
666
668
  } catch (error) {
@@ -698,10 +700,42 @@ export function apply(ctx, config) {
698
700
  if (out?.task !== undefined) {
699
701
  queueOutbound(id, out.task.column)
700
702
  if (acted !== undefined) out.command = acted
703
+ if (input.column === 'cleanup' && was?.worktree) {
704
+ const dirty = await checkWorktreeDirty({ worktreePath: was.worktree })
705
+ if (dirty.dirty) {
706
+ out.worktreeWarning = 'worktree-dirty'
707
+ out.dirtyFiles = dirty.files
708
+ }
709
+ }
701
710
  }
702
711
  reply(res, out)
703
712
  return
704
713
  }
714
+ if (req.method === 'POST' && action === 'cleanup-worktree') {
715
+ const task = store.getTask(id)
716
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
717
+ const out = await cleanupTaskWorktree({
718
+ store,
719
+ task,
720
+ workspaceRegistry: ctx.get('workspaceRegistry'),
721
+ force: input?.force === true,
722
+ })
723
+ reply(res, out)
724
+ return
725
+ }
726
+ if (req.method === 'GET' && action === 'worktree-status') {
727
+ const task = store.getTask(id)
728
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
729
+ const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
730
+ reply(res, {
731
+ worktree: task.worktree || '',
732
+ branch: task.branch || '',
733
+ workspaceId: task.workspaceId || '',
734
+ dirty: dirtyCheck.dirty,
735
+ files: dirtyCheck.files,
736
+ })
737
+ return
738
+ }
705
739
  if (req.method === 'POST' && action === 'stop') {
706
740
  const task = store.getTask(id)
707
741
  if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
package/lib/launcher.js CHANGED
@@ -12,7 +12,8 @@
12
12
  // Пакеты ядра здесь не импортируются: идентификатор сессии и сборка сообщения
13
13
  // приходят параметрами. Так модуль проверяется без харнесса и без сети.
14
14
 
15
- import { isAbsolute, join, resolve } from 'node:path'
15
+ import { isAbsolute, join, resolve, sep } from 'node:path'
16
+ import { createTaskWorktree, registerDshWorkspace } from './worktree.js'
16
17
 
17
18
  /**
18
19
  * Хвост своей задачи — УСЛОВИЕ, а не приказ.
@@ -110,7 +111,8 @@ export function resolveCwd(task, config, cwdOf = () => process.cwd()) {
110
111
 
111
112
  const target = resolve(join(root, repo))
112
113
  const fence = resolve(root)
113
- if (target !== fence && !target.startsWith(fence.endsWith('/') ? fence : fence + '/')) {
114
+ const fenceSep = fence.endsWith(sep) || fence.endsWith('/') ? fence : fence + sep
115
+ if (target !== fence && !target.startsWith(fenceSep) && !target.startsWith(fence.endsWith('/') ? fence : fence + '/')) {
114
116
  throw new Error('имя репозитория выводит за пределы корня проектов')
115
117
  }
116
118
  return target
@@ -145,7 +147,7 @@ export function applyPermission({ permissions, agent, name }) {
145
147
 
146
148
  export async function obtainAgent({
147
149
  agents, task, config, provider, model, mintSessionId, cwdOf, logger,
148
- agentPreset, permission, permissions,
150
+ agentPreset, permission, permissions, gitRunner, workspaceRegistry,
149
151
  }) {
150
152
  const known = typeof task?.sessionId === 'string' && task.sessionId !== '' ? task.sessionId : undefined
151
153
 
@@ -164,12 +166,51 @@ export async function obtainAgent({
164
166
  }
165
167
  }
166
168
 
167
- const cwd = resolveCwd(task, config, cwdOf)
169
+ let cwd = resolveCwd(task, config, cwdOf)
170
+ let worktreePath = task?.worktree || undefined
171
+ let branchName = task?.branch || undefined
172
+ let workspaceId = task?.workspaceId || undefined
173
+ let worktreeCreated = false
174
+
175
+ if (config?.worktreeIsolation !== false && task?.repo) {
176
+ try {
177
+ const created = await createTaskWorktree({
178
+ task,
179
+ repoDir: cwd,
180
+ config,
181
+ gitRunner,
182
+ cwdOf,
183
+ })
184
+ if (created) {
185
+ worktreePath = created.worktreePath
186
+ branchName = created.branchName
187
+ worktreeCreated = created.created === true
188
+ cwd = created.worktreePath
189
+
190
+ if (workspaceRegistry) {
191
+ workspaceId = await registerDshWorkspace({
192
+ workspaceRegistry,
193
+ worktreePath,
194
+ title: `${task.owner ? task.owner + '/' : ''}${task.repo}#${task.issueNumber || task.id.slice(0, 8)}: ${task.title}`,
195
+ })
196
+ }
197
+ }
198
+ } catch (err) {
199
+ logger?.warn?.(`dsh-kanban: не удалось создать worktree для ${task.id}: ${err.message}`)
200
+ }
201
+ } else if (worktreePath) {
202
+ cwd = worktreePath
203
+ }
204
+
205
+ const meta = { cwd }
206
+ if (typeof agentPreset === 'string' && agentPreset !== '') meta.agentPreset = agentPreset
207
+ if (workspaceId) meta.workspaceId = workspaceId
208
+
168
209
  const handle = await agents.create({
169
210
  sessionId: mintSessionId(),
170
211
  // Профиль — факт создания сессии: у идущей его не сменить, поэтому он
171
212
  // едет здесь, а не отдельным вызовом после.
172
- meta: typeof agentPreset === 'string' && agentPreset !== '' ? { cwd, agentPreset } : { cwd },
213
+ meta,
173
214
  agentOptions: { provider, model },
174
215
  })
175
216
  const access = applyPermission({ permissions, agent: handle.agent, name: permission })
@@ -182,7 +223,15 @@ export async function obtainAgent({
182
223
  error.status = 409
183
224
  throw error
184
225
  }
185
- return { agent: handle.agent, sessionId: handle.agent.session.id, mode: 'created' }
226
+ return {
227
+ agent: handle.agent,
228
+ sessionId: handle.agent.session.id,
229
+ mode: 'created',
230
+ worktree: worktreePath,
231
+ branch: branchName,
232
+ workspaceId,
233
+ worktreeCreated,
234
+ }
186
235
  }
187
236
 
188
237
  /**
@@ -240,10 +289,11 @@ export async function runTask({
240
289
  agents, store, task, config, provider, model,
241
290
  mintSessionId, createMessage, cwdOf, logger, text,
242
291
  agentPreset, permission, permissions, claimAs,
292
+ gitRunner, workspaceRegistry,
243
293
  }) {
244
294
  const taken = await obtainAgent({
245
295
  agents, task, config, provider, model, mintSessionId, cwdOf, logger,
246
- agentPreset, permission, permissions,
296
+ agentPreset, permission, permissions, gitRunner, workspaceRegistry,
247
297
  })
248
298
 
249
299
  await taken.agent.whenIdle()
@@ -255,12 +305,30 @@ export async function runTask({
255
305
  }))
256
306
 
257
307
  const before = store.getTask(task.id)
258
- const patch = { sessionId: taken.sessionId, provider, model }
308
+ const patch = {
309
+ sessionId: taken.sessionId,
310
+ provider,
311
+ model,
312
+ ...(taken.worktree ? { worktree: taken.worktree } : {}),
313
+ ...(taken.branch ? { branch: taken.branch } : {}),
314
+ ...(taken.workspaceId ? { workspaceId: taken.workspaceId } : {}),
315
+ }
259
316
  store.updateTask(task.id, patch)
260
317
  // Запустил — значит отвечаешь: свободная задача становится твоей, чужая
261
318
  // остаётся чужой.
262
319
  claimOnStart({ store, task: before, login: claimAs })
263
320
 
321
+ // Изоляция сессии в отдельном воркдереве фиксируется в журнале.
322
+ if (taken.worktreeCreated) {
323
+ store.addTransition({
324
+ taskId: task.id,
325
+ fromCol: before.column,
326
+ toCol: before.column,
327
+ source: 'session',
328
+ detail: `изоляция: создано воркдерево ${taken.worktree} (${taken.branch})`,
329
+ })
330
+ }
331
+
264
332
  // Подмена чата не должна быть молчаливой: человек вернётся к задаче и не
265
333
  // поймёт, куда делась переписка.
266
334
  if (taken.mode === 'created' && before.sessionId) {
package/lib/store.js CHANGED
@@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS tasks (
40
40
  labels TEXT NOT NULL DEFAULT '[]',
41
41
  branch TEXT,
42
42
  worktree TEXT,
43
+ workspaceId TEXT NOT NULL DEFAULT '',
43
44
  sessionId TEXT,
44
45
  model TEXT,
45
46
  provider TEXT,
@@ -82,7 +83,7 @@ CREATE TABLE IF NOT EXISTS dismissed (
82
83
  /** Поля задачи, которые разрешено править через updateTask. */
83
84
  const WRITABLE = [
84
85
  'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
85
- 'branch', 'worktree', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
86
+ 'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
86
87
  'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
87
88
  ]
88
89
 
@@ -132,6 +133,7 @@ export function openStore({ dir }) {
132
133
  ['milestone', "TEXT NOT NULL DEFAULT ''"],
133
134
  ['priority', "TEXT NOT NULL DEFAULT ''"],
134
135
  ['dueAt', 'INTEGER NOT NULL DEFAULT 0'],
136
+ ['workspaceId', "TEXT NOT NULL DEFAULT ''"],
135
137
  ]) {
136
138
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
137
139
  }
@@ -260,15 +262,15 @@ export function openStore({ dir }) {
260
262
  const position = positionFor(board, column, input.beforeId, input.afterId)
261
263
  db.prepare(`INSERT INTO tasks
262
264
  (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
263
- labels, branch, worktree, sessionId, model, provider, waiting, syncedAt,
265
+ labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
264
266
  labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt)
265
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
267
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
266
268
  id, board, column, position,
267
269
  input.title ?? '', input.body ?? '',
268
270
  input.owner ?? null, input.repo ?? null,
269
271
  input.issueNumber ?? null, input.issueUrl ?? null,
270
272
  JSON.stringify(input.labels ?? []),
271
- input.branch ?? null, input.worktree ?? null,
273
+ input.branch ?? null, input.worktree ?? null, input.workspaceId ?? '',
272
274
  input.sessionId ?? null, input.model ?? null, input.provider ?? null,
273
275
  input.waiting ? 1 : 0,
274
276
  Number(input.syncedAt) || 0,
@@ -0,0 +1,339 @@
1
+ // Управляемые воркдеревья для сессий агентов под задачи (Issue #193).
2
+ //
3
+ // Изоляция сессии агента в отдельном git worktree защищает основной
4
+ // репозиторий от параллельных правок нескольких задач, предотвращает
5
+ // перезапись веток и фиксирует рабочее окружение карточки.
6
+ //
7
+ // Жизненный цикл:
8
+ // 1. При переходе в In Progress создаётся воркдерево task/<id>-<slug>
9
+ // 2. Воркдерево регистрируется в DSH workspaceRegistry как рабочий каталог
10
+ // 3. Сессия агента запускается с cwd = worktreePath
11
+ // 4. В колонке Cleanup проверяются незакоммиченные файлы (dirtyFiles)
12
+ // 5. После подтверждения воркдерево удаляется, воркспейс дерегистрируется.
13
+
14
+ import { execFile } from 'node:child_process'
15
+ import { promisify } from 'node:util'
16
+ import { existsSync } from 'node:fs'
17
+ import { isAbsolute, join, resolve, sep } from 'node:path'
18
+
19
+ const execFileAsync = promisify(execFile)
20
+
21
+ /**
22
+ * Стандартный исполнитель git-команд.
23
+ * Принимает аргументы массивом, исключая шелл-инъекции.
24
+ */
25
+ export async function defaultGitRunner(args, cwd) {
26
+ try {
27
+ const { stdout, stderr } = await execFileAsync('git', args, {
28
+ cwd,
29
+ maxBuffer: 10 * 1024 * 1024,
30
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
31
+ })
32
+ return { stdout: stdout.trim(), stderr: stderr.trim(), code: 0 }
33
+ } catch (err) {
34
+ const error = new Error(`git ${args.join(' ')} failed: ${err.stderr || err.message}`)
35
+ error.code = err.code || 1
36
+ error.stdout = err.stdout ? String(err.stdout).trim() : ''
37
+ error.stderr = err.stderr ? String(err.stderr).trim() : ''
38
+ throw error
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Преобразовать строку в безопасный фрагмент имени ветки или каталога.
44
+ */
45
+ export function slugify(text) {
46
+ if (typeof text !== 'string') return 'task'
47
+ const slug = text
48
+ .toLowerCase()
49
+ .normalize('NFD')
50
+ .replace(/[\u0300-\u036f]/g, '')
51
+ .replace(/[^a-z0-9а-яё_-]+/gi, '-')
52
+ .replace(/-+/g, '-')
53
+ .replace(/^-|-$/g, '')
54
+ .slice(0, 40)
55
+ return slug || 'task'
56
+ }
57
+
58
+ /**
59
+ * Безопасный ключ репозитория для каталога воркдеревьев.
60
+ */
61
+ export function safeRepoKey(task) {
62
+ const owner = typeof task?.owner === 'string' ? task.owner.trim() : ''
63
+ const repo = typeof task?.repo === 'string' ? task.repo.trim() : ''
64
+ if (owner && repo) return `${owner}_${repo}`
65
+ if (repo) return repo
66
+ return 'local'
67
+ }
68
+
69
+ /**
70
+ * Определить корневой каталог для всех управляемых воркдеревьев.
71
+ */
72
+ export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => process.cwd()) {
73
+ const custom = typeof config?.worktreeRoot === 'string' ? config.worktreeRoot.trim() : ''
74
+ if (custom !== '') {
75
+ return isAbsolute(custom) ? resolve(custom) : resolve(join(cwdOf(), custom))
76
+ }
77
+ if (typeof env?.DSH_HOME === 'string' && env.DSH_HOME.trim() !== '') {
78
+ return resolve(join(env.DSH_HOME.trim(), 'worktrees'))
79
+ }
80
+ const projectRoot = typeof config?.defaultProjectRoot === 'string' && config.defaultProjectRoot.trim() !== ''
81
+ ? config.defaultProjectRoot.trim()
82
+ : cwdOf()
83
+ return resolve(join(projectRoot, '.worktrees'))
84
+ }
85
+
86
+ /**
87
+ * Вычислить канонический путь к воркдереву задачи с защитой от path traversal.
88
+ */
89
+ export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
90
+ const root = resolveWorktreeRoot(config, env, cwdOf)
91
+ const repoKey = safeRepoKey(task)
92
+ const taskId = typeof task?.id === 'string' && task.id.trim() !== '' ? task.id.trim() : 'task'
93
+ const target = resolve(join(root, repoKey, taskId))
94
+
95
+ const fenceSep = root.endsWith(sep) || root.endsWith('/') ? root : root + sep
96
+ if (target !== root && !target.startsWith(fenceSep) && !target.startsWith(root + '/')) {
97
+ throw new Error('путь воркдерева выходит за пределы корня воркдеревьев')
98
+ }
99
+ return target
100
+ }
101
+
102
+ /**
103
+ * Сформировать имя ветки задачи.
104
+ */
105
+ export function resolveTaskBranch(task) {
106
+ const prefix = typeof task?.issueNumber === 'number'
107
+ ? String(task.issueNumber)
108
+ : (typeof task?.id === 'string' ? task.id.slice(0, 8) : 'task')
109
+ const slug = slugify(task?.title || '')
110
+ return `task/${prefix}-${slug}`
111
+ }
112
+
113
+ /**
114
+ * Проверить, является ли каталог git-репозиторием.
115
+ */
116
+ export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
117
+ if (!repoDir) return false
118
+ try {
119
+ const res = await gitRunner(['rev-parse', '--is-inside-work-tree'], repoDir)
120
+ return res.stdout === 'true'
121
+ } catch {
122
+ return false
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Получить список существующих путей воркдеревьев для репозитория.
128
+ */
129
+ export async function listWorktrees(repoDir, gitRunner = defaultGitRunner) {
130
+ try {
131
+ const res = await gitRunner(['worktree', 'list', '--porcelain'], repoDir)
132
+ const paths = []
133
+ for (const line of res.stdout.split('\n')) {
134
+ if (line.startsWith('worktree ')) {
135
+ paths.push(resolve(line.slice(9).trim()))
136
+ }
137
+ }
138
+ return paths
139
+ } catch {
140
+ return []
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Определить базовую ветку для создания воркдерева.
146
+ */
147
+ export async function determineBaseRef(repoDir, gitRunner = defaultGitRunner) {
148
+ for (const candidate of ['origin/main', 'origin/master', 'main', 'master', 'HEAD']) {
149
+ try {
150
+ await gitRunner(['rev-parse', '--verify', '--quiet', candidate], repoDir)
151
+ return candidate
152
+ } catch {
153
+ continue
154
+ }
155
+ }
156
+ return 'HEAD'
157
+ }
158
+
159
+ /**
160
+ * Создать или переиспользовать воркдерево под задачу.
161
+ */
162
+ export async function createTaskWorktree({
163
+ task,
164
+ repoDir,
165
+ config,
166
+ gitRunner = defaultGitRunner,
167
+ env,
168
+ cwdOf,
169
+ }) {
170
+ if (!repoDir) return undefined
171
+ const isGit = await isGitRepo(repoDir, gitRunner)
172
+ if (!isGit) return undefined
173
+
174
+ const worktreePath = resolveTaskWorktreePath({ task, config, env, cwdOf })
175
+ const branchName = resolveTaskBranch(task)
176
+
177
+ // Проверяем, не зарегистрировано ли уже это воркдерево
178
+ const existing = await listWorktrees(repoDir, gitRunner)
179
+ if (existing.includes(resolve(worktreePath))) {
180
+ return { worktreePath, branchName, reused: true }
181
+ }
182
+
183
+ // Проверяем, существует ли уже локальная ветка с таким именем
184
+ let branchExists = false
185
+ try {
186
+ await gitRunner(['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`], repoDir)
187
+ branchExists = true
188
+ } catch {
189
+ branchExists = false
190
+ }
191
+
192
+ if (branchExists) {
193
+ await gitRunner(['worktree', 'add', worktreePath, branchName], repoDir)
194
+ } else {
195
+ const baseRef = await determineBaseRef(repoDir, gitRunner)
196
+ await gitRunner(['worktree', 'add', '-b', branchName, worktreePath, baseRef], repoDir)
197
+ }
198
+
199
+ return { worktreePath, branchName, created: true }
200
+ }
201
+
202
+ /**
203
+ * Проверить наличие незакоммиченных изменений в воркдереве.
204
+ */
205
+ export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitRunner }) {
206
+ if (!worktreePath || !existsSync(worktreePath)) {
207
+ return { dirty: false, files: [] }
208
+ }
209
+ try {
210
+ const res = await gitRunner(['status', '--porcelain'], worktreePath)
211
+ const files = res.stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
212
+ return { dirty: files.length > 0, files }
213
+ } catch (error) {
214
+ return { dirty: false, files: [], error: String(error?.message ?? error) }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Безопасно удалить воркдерево задачи.
220
+ */
221
+ export async function removeTaskWorktree({
222
+ repoDir,
223
+ worktreePath,
224
+ branchName,
225
+ force = false,
226
+ deleteBranch = true,
227
+ gitRunner = defaultGitRunner,
228
+ }) {
229
+ if (!worktreePath || !existsSync(worktreePath)) {
230
+ return { removed: false, reason: 'not-found' }
231
+ }
232
+
233
+ const dirtyCheck = await checkWorktreeDirty({ worktreePath, gitRunner })
234
+ if (dirtyCheck.dirty && !force) {
235
+ return {
236
+ removed: false,
237
+ error: 'worktree-dirty',
238
+ files: dirtyCheck.files,
239
+ }
240
+ }
241
+
242
+ const removeArgs = force
243
+ ? ['worktree', 'remove', '--force', worktreePath]
244
+ : ['worktree', 'remove', worktreePath]
245
+
246
+ await gitRunner(removeArgs, repoDir)
247
+ try { await gitRunner(['worktree', 'prune'], repoDir) } catch { /* prune игнорирует ошибки */ }
248
+
249
+ let branchDeleted = false
250
+ if (deleteBranch && branchName && repoDir) {
251
+ try {
252
+ await gitRunner(['branch', '-d', branchName], repoDir)
253
+ branchDeleted = true
254
+ } catch {
255
+ // Игнорируем: ветка не удалена, если ещё не влита
256
+ branchDeleted = false
257
+ }
258
+ }
259
+
260
+ return { removed: true, branchDeleted }
261
+ }
262
+
263
+ /**
264
+ * Зарегистрировать каталог воркдерева в DSH workspaceRegistry.
265
+ */
266
+ export async function registerDshWorkspace({ workspaceRegistry, worktreePath, title }) {
267
+ if (!workspaceRegistry || typeof workspaceRegistry.create !== 'function') return undefined
268
+ try {
269
+ const entity = await workspaceRegistry.create(worktreePath, title || 'Task Workspace')
270
+ return entity?.id
271
+ } catch {
272
+ return undefined
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Дерегистрировать воркспейс из DSH workspaceRegistry.
278
+ */
279
+ export async function deregisterDshWorkspace({ workspaceRegistry, workspaceId }) {
280
+ if (!workspaceRegistry || typeof workspaceRegistry.delete !== 'function' || !workspaceId) return false
281
+ try {
282
+ return (await workspaceRegistry.delete(workspaceId)) === true
283
+ } catch {
284
+ return false
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Выполнить очистку воркдерева задачи с проверкой незакоммиченных изменений.
290
+ */
291
+ export async function cleanupTaskWorktree({
292
+ store,
293
+ task,
294
+ repoDir,
295
+ workspaceRegistry,
296
+ force = false,
297
+ deleteBranch = true,
298
+ gitRunner = defaultGitRunner,
299
+ }) {
300
+ const current = typeof task === 'object' && task !== null ? task : store?.getTask?.(task)
301
+ if (!current) return { error: 'task-not-found', status: 404 }
302
+ if (!current.worktree) return { noop: true, removed: false }
303
+
304
+ const targetRepoDir = repoDir || current.worktree
305
+ const result = await removeTaskWorktree({
306
+ repoDir: targetRepoDir,
307
+ worktreePath: current.worktree,
308
+ branchName: current.branch,
309
+ force,
310
+ deleteBranch,
311
+ gitRunner,
312
+ })
313
+
314
+ if (result.error === 'worktree-dirty') {
315
+ store?.addTransition?.({
316
+ taskId: current.id,
317
+ fromCol: current.column,
318
+ toCol: current.column,
319
+ source: 'session',
320
+ detail: `очистка отложена: обнаружены незакоммиченные файлы: ${result.files.slice(0, 3).join(', ')}`,
321
+ })
322
+ return { error: 'worktree-dirty', files: result.files }
323
+ }
324
+
325
+ if (workspaceRegistry && current.workspaceId) {
326
+ await deregisterDshWorkspace({ workspaceRegistry, workspaceId: current.workspaceId })
327
+ }
328
+
329
+ store?.updateTask?.(current.id, { worktree: '', workspaceId: '' })
330
+ store?.addTransition?.({
331
+ taskId: current.id,
332
+ fromCol: current.column,
333
+ toCol: current.column,
334
+ source: 'session',
335
+ detail: 'очистка: воркдерево удалено' + (result.branchDeleted ? ', ветка удалена' : ''),
336
+ })
337
+
338
+ return { success: true, removed: true, branchDeleted: result.branchDeleted }
339
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
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",