@goodandready/dsh-kanban 0.1.24 → 0.1.26

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.
@@ -0,0 +1,134 @@
1
+ // dsh-kanban: фиксация и безопасная передача среза прогресса задачи (PROGRESSDUMP).
2
+ // Парсит блок <<<PROGRESSDUMP ... >>>PROGRESSDUMP, маскирует секреты и генерирует вводный бриф.
3
+
4
+ export const BEGIN_MARKER = '<<<PROGRESSDUMP'
5
+ export const END_MARKER = '>>>PROGRESSDUMP'
6
+ export const FIELD_BYTE_LIMIT = 8 * 1024
7
+ export const REDACTED = '[REDACTED]'
8
+
9
+ const SENSITIVE_PATTERNS = [
10
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
11
+ /Bearer\s+[A-Za-z0-9._~+\/=:-]{8,}/gi,
12
+ /sk-[A-Za-z0-9_-]{8,}/g,
13
+ /ghp_[A-Za-z0-9]{20,}/g,
14
+ /glpat-[A-Za-z0-9_-]{15,}/g,
15
+ /xox[bpars]-[A-Za-z0-9-]{10,}/g,
16
+ ]
17
+
18
+ function sanitizeText(raw) {
19
+ let text = raw
20
+ let redacted = false
21
+ for (const pattern of SENSITIVE_PATTERNS) {
22
+ if (pattern.test(text)) {
23
+ redacted = true
24
+ text = text.replace(pattern, REDACTED)
25
+ }
26
+ }
27
+ // Блокируем внедрение слэш-команд харнесса
28
+ text = text.split('\n')
29
+ .map((line) => (line.trim().startsWith('/') ? ('# ' + line) : line))
30
+ .join('\n')
31
+
32
+ return { text, redacted }
33
+ }
34
+
35
+ function truncateBytes(str, maxBytes) {
36
+ const buf = Buffer.from(str, 'utf8')
37
+ if (buf.length <= maxBytes) return str
38
+ return buf.subarray(0, maxBytes).toString('utf8')
39
+ }
40
+
41
+ /**
42
+ * Разобрать блок PROGRESSDUMP из произвольного текста.
43
+ * @param {string} text
44
+ * @returns {{ ok: true, dump: { goal: string, progress: string, next: string, redacted: boolean } } | { ok: false, error: string }}
45
+ */
46
+ export function parseProgressDump(text) {
47
+ if (typeof text !== 'string') return { ok: false, error: 'text-required' }
48
+
49
+ const startIdx = text.indexOf(BEGIN_MARKER)
50
+ const endIdx = text.indexOf(END_MARKER)
51
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
52
+ return { ok: false, error: 'markers-not-found' }
53
+ }
54
+
55
+ const inner = text.slice(startIdx + BEGIN_MARKER.length, endIdx).trim()
56
+ const lines = inner.split('\n')
57
+
58
+ let currentKey = null
59
+ const sections = { goal: [], progress: [], next: [] }
60
+
61
+ for (const rawLine of lines) {
62
+ const line = rawLine.trim()
63
+ const lower = line.toLowerCase()
64
+
65
+ if (lower.startsWith('цель:') || lower.startsWith('goal:')) {
66
+ currentKey = 'goal'
67
+ const val = line.slice(line.indexOf(':') + 1).trim()
68
+ if (val) sections.goal.push(val)
69
+ } else if (lower.startsWith('прогресс:') || lower.startsWith('progress:')) {
70
+ currentKey = 'progress'
71
+ const val = line.slice(line.indexOf(':') + 1).trim()
72
+ if (val) sections.progress.push(val)
73
+ } else if (lower.startsWith('следующие шаги:') || lower.startsWith('next:') || lower.startsWith('шаги:')) {
74
+ currentKey = 'next'
75
+ const val = line.slice(line.indexOf(':') + 1).trim()
76
+ if (val) sections.next.push(val)
77
+ } else if (currentKey) {
78
+ sections[currentKey].push(rawLine)
79
+ }
80
+ }
81
+
82
+ let totalRedacted = false
83
+ const result = { goal: '', progress: '', next: '', redacted: false }
84
+
85
+ for (const key of ['goal', 'progress', 'next']) {
86
+ const rawVal = sections[key].join('\n').trim()
87
+ const { text: cleanText, redacted } = sanitizeText(rawVal)
88
+ if (redacted) totalRedacted = true
89
+ result[key] = truncateBytes(cleanText, FIELD_BYTE_LIMIT)
90
+ }
91
+
92
+ result.redacted = totalRedacted
93
+ return { ok: true, dump: result }
94
+ }
95
+
96
+ /**
97
+ * Сериализовать срез в канонический блок <<<PROGRESSDUMP ... >>>PROGRESSDUMP.
98
+ */
99
+ export function formatProgressDump(dump) {
100
+ if (!dump) return ''
101
+ return [
102
+ BEGIN_MARKER,
103
+ 'Цель:',
104
+ dump.goal || '—',
105
+ '',
106
+ 'Прогресс:',
107
+ dump.progress || '—',
108
+ '',
109
+ 'Следующие шаги:',
110
+ dump.next || '—',
111
+ END_MARKER,
112
+ ].join('\n')
113
+ }
114
+
115
+ /**
116
+ * Сформировать аккуратный вводный бриф для новой сессии агента.
117
+ */
118
+ export function formatHandoverPreamble(dump) {
119
+ if (!dump || (!dump.goal && !dump.progress && !dump.next)) return ''
120
+ return [
121
+ '## 📋 Эстафета задачи (PROGRESSDUMP)',
122
+ '> [!NOTE]',
123
+ '> Предыдущий ход зафиксировал текущее состояние работы:',
124
+ '',
125
+ '**Цель:** ' + (dump.goal || '—'),
126
+ '',
127
+ '**Сделано:** ' + (dump.progress || '—'),
128
+ '',
129
+ '**Следующие шаги:** ' + (dump.next || '—'),
130
+ '',
131
+ '---',
132
+ '',
133
+ ].join('\n')
134
+ }
package/lib/routes.js CHANGED
@@ -14,6 +14,7 @@ import { columnStats, doneWithin, stale } from './metrics.js'
14
14
  import { isStale } from './time.js'
15
15
  import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from './filters.js'
16
16
  import { parseBody } from './markdown.js'
17
+ import { computeNextRun } from './cron.js'
17
18
 
18
19
  /** Больше этого тело запроса не читаем: без предела один запрос кладёт хост. */
19
20
  export const MAX_BODY_BYTES = 256 * 1024
@@ -183,7 +184,8 @@ export function createTask({ store, input }) {
183
184
 
184
185
  /** Правка полей карточки. Колонку и порядок правит только перенос. */
185
186
  export function updateTask({ store, id, input }) {
186
- if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
187
+ const current = store.getTask(id)
188
+ if (current === undefined) return { error: 'task-not-found', status: 404 }
187
189
  const patch = {}
188
190
  if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
189
191
  if (typeof input?.body === 'string') patch.body = input.body
@@ -197,9 +199,28 @@ export function updateTask({ store, id, input }) {
197
199
  if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
198
200
  patch.dueAt = Math.floor(input.dueAt)
199
201
  }
202
+ if (typeof input?.cron === 'string') {
203
+ patch.cron = input.cron.trim()
204
+ patch.nextRunAt = patch.cron ? (computeNextRun(patch.cron) || 0) : 0
205
+ }
206
+ if (typeof input?.progressDump === 'string') {
207
+ patch.progressDump = input.progressDump
208
+ }
209
+ if (typeof input?.permission === 'string') {
210
+ patch.permission = input.permission
211
+ if (input.permission !== current.permission) {
212
+ patch.permissionConfirmedAt = 0
213
+ }
214
+ }
200
215
  return { task: store.updateTask(id, patch) }
201
216
  }
202
217
 
218
+ export function confirmPermission({ store, id }) {
219
+ const task = store.getTask(id)
220
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
221
+ return { task: store.updateTask(id, { permissionConfirmedAt: Date.now() }) }
222
+ }
223
+
203
224
  /** Известные приоритеты. Пустой — «без приоритета». */
204
225
  export const PRIORITIES = ['', 'high', 'medium', 'low']
205
226
 
package/lib/store.js CHANGED
@@ -40,6 +40,14 @@ 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 '',
44
+ cron TEXT NOT NULL DEFAULT '',
45
+ nextRunAt INTEGER NOT NULL DEFAULT 0,
46
+ lastRunAt INTEGER NOT NULL DEFAULT 0,
47
+ progressDump TEXT NOT NULL DEFAULT '',
48
+ permission TEXT NOT NULL DEFAULT '',
49
+ permissionConfirmedAt INTEGER NOT NULL DEFAULT 0,
50
+ runs TEXT NOT NULL DEFAULT '[]',
43
51
  sessionId TEXT,
44
52
  model TEXT,
45
53
  provider TEXT,
@@ -82,8 +90,9 @@ CREATE TABLE IF NOT EXISTS dismissed (
82
90
  /** Поля задачи, которые разрешено править через updateTask. */
83
91
  const WRITABLE = [
84
92
  'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
85
- 'branch', 'worktree', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
93
+ 'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
86
94
  'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
95
+ 'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'permissionConfirmedAt', 'runs',
87
96
  ]
88
97
 
89
98
  /** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
@@ -91,7 +100,7 @@ const SOURCES = new Set(['manual', 'session', 'gitea', 'tool'])
91
100
 
92
101
  function rowToTask(row) {
93
102
  if (row === undefined) return undefined
94
- const { col, labels, waiting, labelColors, ...rest } = row
103
+ const { col, labels, waiting, labelColors, runs, ...rest } = row
95
104
  let parsed = []
96
105
  try {
97
106
  const value = JSON.parse(labels)
@@ -102,7 +111,12 @@ function rowToTask(row) {
102
111
  const value = JSON.parse(labelColors ?? '{}')
103
112
  if (value !== null && typeof value === 'object' && !Array.isArray(value)) colors = value
104
113
  } catch { /* испорченное поле не должно ронять чтение всей доски */ }
105
- return { ...rest, column: col, labels: parsed, labelColors: colors, waiting: waiting === 1 }
114
+ let parsedRuns = []
115
+ try {
116
+ const value = JSON.parse(runs ?? '[]')
117
+ if (Array.isArray(value)) parsedRuns = value
118
+ } catch { /* испорченное поле не должно ронять чтение всей доски */ }
119
+ return { ...rest, column: col, labels: parsed, labelColors: colors, waiting: waiting === 1, runs: parsedRuns }
106
120
  }
107
121
 
108
122
  /**
@@ -132,6 +146,14 @@ export function openStore({ dir }) {
132
146
  ['milestone', "TEXT NOT NULL DEFAULT ''"],
133
147
  ['priority', "TEXT NOT NULL DEFAULT ''"],
134
148
  ['dueAt', 'INTEGER NOT NULL DEFAULT 0'],
149
+ ['workspaceId', "TEXT NOT NULL DEFAULT ''"],
150
+ ['cron', "TEXT NOT NULL DEFAULT ''"],
151
+ ['nextRunAt', 'INTEGER NOT NULL DEFAULT 0'],
152
+ ['lastRunAt', 'INTEGER NOT NULL DEFAULT 0'],
153
+ ['progressDump', "TEXT NOT NULL DEFAULT ''"],
154
+ ['permission', "TEXT NOT NULL DEFAULT ''"],
155
+ ['permissionConfirmedAt', 'INTEGER NOT NULL DEFAULT 0'],
156
+ ['runs', "TEXT NOT NULL DEFAULT '[]'"],
135
157
  ]) {
136
158
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
137
159
  }
@@ -163,6 +185,8 @@ export function openStore({ dir }) {
163
185
  countInColumn: db.prepare('SELECT COUNT(*) AS n FROM tasks WHERE board = ? AND col = ?'),
164
186
  watchable: db.prepare(
165
187
  "SELECT * FROM tasks WHERE issueNumber IS NOT NULL AND col NOT IN ('done') ORDER BY updatedAt DESC"),
188
+ scheduledDue: db.prepare(
189
+ "SELECT * FROM tasks WHERE cron != '' AND nextRunAt > 0 AND nextRunAt <= ? AND archivedAt = 0 ORDER BY nextRunAt ASC"),
166
190
  }
167
191
 
168
192
  function ensureBoard(id, title, kind) {
@@ -260,15 +284,16 @@ export function openStore({ dir }) {
260
284
  const position = positionFor(board, column, input.beforeId, input.afterId)
261
285
  db.prepare(`INSERT INTO tasks
262
286
  (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
263
- labels, branch, worktree, sessionId, model, provider, waiting, syncedAt,
264
- labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt)
265
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
287
+ labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
288
+ labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
289
+ cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs)
290
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
266
291
  id, board, column, position,
267
292
  input.title ?? '', input.body ?? '',
268
293
  input.owner ?? null, input.repo ?? null,
269
294
  input.issueNumber ?? null, input.issueUrl ?? null,
270
295
  JSON.stringify(input.labels ?? []),
271
- input.branch ?? null, input.worktree ?? null,
296
+ input.branch ?? null, input.worktree ?? null, input.workspaceId ?? '',
272
297
  input.sessionId ?? null, input.model ?? null, input.provider ?? null,
273
298
  input.waiting ? 1 : 0,
274
299
  Number(input.syncedAt) || 0,
@@ -279,6 +304,13 @@ export function openStore({ dir }) {
279
304
  input.priority ?? '',
280
305
  Number(input.dueAt) || 0,
281
306
  now, born, now,
307
+ input.cron ?? '',
308
+ Number(input.nextRunAt) || 0,
309
+ Number(input.lastRunAt) || 0,
310
+ input.progressDump ?? '',
311
+ input.permission ?? '',
312
+ Number(input.permissionConfirmedAt) || 0,
313
+ typeof input.runs === 'string' ? input.runs : JSON.stringify(input.runs ?? []),
282
314
  )
283
315
  return rowToTask(q.taskById.get(id))
284
316
  },
@@ -293,6 +325,7 @@ export function openStore({ dir }) {
293
325
  fields.push(`${key} = ?`)
294
326
  if (key === 'labels') values.push(JSON.stringify(patch[key] ?? []))
295
327
  else if (key === 'labelColors') values.push(JSON.stringify(patch[key] ?? {}))
328
+ else if (key === 'runs') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
296
329
  else if (key === 'waiting') values.push(patch[key] ? 1 : 0)
297
330
  else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
298
331
  else values.push(patch[key] ?? null)
@@ -304,6 +337,31 @@ export function openStore({ dir }) {
304
337
  return rowToTask(q.taskById.get(id))
305
338
  },
306
339
 
340
+ listDueTasks(now = Date.now()) {
341
+ return q.scheduledDue.all(now).map(rowToTask)
342
+ },
343
+
344
+ recordTaskRun(id, run) {
345
+ const task = rowToTask(q.taskById.get(id))
346
+ if (!task) return undefined
347
+ const runs = Array.isArray(task.runs) ? [...task.runs] : []
348
+ runs.unshift(run)
349
+ const trimmed = runs.slice(0, 20)
350
+ this.updateTask(id, { runs: JSON.stringify(trimmed) })
351
+ return rowToTask(q.taskById.get(id))
352
+ },
353
+
354
+ updateTaskRun(id, runId, patch) {
355
+ const task = rowToTask(q.taskById.get(id))
356
+ if (!task) return undefined
357
+ const runs = Array.isArray(task.runs) ? [...task.runs] : []
358
+ const idx = runs.findIndex((r) => r.id === runId)
359
+ if (idx === -1) return undefined
360
+ runs[idx] = { ...runs[idx], ...patch }
361
+ this.updateTask(id, { runs: JSON.stringify(runs) })
362
+ return rowToTask(q.taskById.get(id))
363
+ },
364
+
307
365
  moveTask(id, { column, beforeId, afterId }) {
308
366
  const current = q.taskById.get(id)
309
367
  if (current === undefined) throw new Error(`задача ${id} не найдена`)
@@ -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.24",
3
+ "version": "0.1.26",
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",