@goodandready/dsh-kanban 0.1.28 → 0.1.30

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.
@@ -11,7 +11,7 @@
11
11
  - Карточка настроек в слоте settings.plugin.item (пространство dsh-kanban).
12
12
  - Чип активной задачи в conversation.session.header.utilities.
13
13
  - Пункт бокового меню для вызова экрана доски.
14
- - API: HTTP эндпоинты /dsh-kanban/* (board, snapshot, task, sync, webhook, metrics, whoami).
14
+ - API: HTTP эндпоинты /dsh-kanban/* (board, snapshot, task, sync, webhook, metrics, whoami, checklist, report, comments, accept, reject).
15
15
  - CLI: node tools/compat.mjs (проверка совместимости экспортов ядра).
16
16
  - Документация: README.md, docs/README.ru.md, docs/README.zh.md, index.md.
17
17
 
@@ -27,7 +27,7 @@
27
27
  - Accessibility: Нативные кнопки button, role="status" для сообщений, семантичные поля ввода с aria-label/label.
28
28
 
29
29
  ## Components And States
30
- - Компоненты: BoardScreen, Column, Card, TaskDrawer, KanbanSettingsCard, TaskChip, MetricsModal.
30
+ - Компоненты: BoardScreen, Column, Card, TaskDrawer (включая секции DoD Checklist, Execution Report, Comments Thread, Review Acceptance Gate), KanbanSettingsCard, TaskChip, MetricsModal.
31
31
  - Loading / empty / error / success:
32
32
  - Загрузка: состояние скелетона/лоадера без блокировки скролла.
33
33
  - Пустые колонки: пунктирная область перетаскивания.
@@ -52,3 +52,16 @@
52
52
  - 2026-09-02 — Снимок доски объединён на маршруте /dsh-kanban/snapshot (GET: выгрузка, POST: загрузка).
53
53
  - 2026-09-03 — Изоляция сессий задач в git worktree ($DSH_HOME/worktrees/<repo-key>/<task-id>/ на ветке task/<id>-<slug>), регистрация воркдерева в workspaceRegistry, проверка dirtyFiles и безопасная очистка в фазе cleanup (#193).
54
54
  - 2026-09-03 — Серверная инфраструктура автономности и безопасности (#195): Host-Side 5-field Cron, PowerInhibitor (кроссплатформенная блокировка сна системы во время работы задач), PROGRESSDUMP (фиксация срезов контекста, маскирование секретов и передача эстафеты), Permission Confirmation Gate (ручное одобрение прав выше базовых), инъекция секции доски в systemPrompt и хранение истории запусков прямо в карточке задачи (tasks.runs).
55
+
56
+ - 2026-09-07 — Контур качества и приёмка человеком (#205–#209):
57
+ - Протокольный шлюз: колонка done является исключительной границей приёмки человеком; инструменту board_move запрещён прямой перевод задачи в done.
58
+ - DoD Acceptance Checklist: перевод в review блокируется до закрытия обязательных пунктов чеклиста с фиксацией доказательств (evidence).
59
+ - Structured Report: фиксация отчёта выполнения (summary, changedFiles, checksRun, artifacts, risks) в карточке задачи.
60
+ - Двусторонний цикл приёмки: явные действия человека Accept (в done) и Reject (возврат в in-progress с обязательной причиной rejectReason, инжектируемой в следующую сессию агента).
61
+ - Ветка комментариев к задаче с поддержкой ролей user и agent для сквозного диалога прямо в Task Drawer.
62
+
63
+ - 2026-09-08 — Стабильность сессий и воркдеревьев (#227, #226, #213, #215):
64
+ - Привязка проекта сессии: при создании агента с воркдеревом поле meta.cwd сохраняет канонический projectRoot (repoDir), предотвращая выпадение сессий в категорию «Ungrouped» в сайдбаре DSH; путь воркдерева передается в meta.worktreePath и инструкциях агента (#227).
65
+ - Режим «↻ Продолжить» (Resume Execution): эндпоинт POST /dsh-kanban/tasks/:id/resume и кнопка в TaskDrawer; сохраняет существующее воркдерево, ветку и незакоммиченные файлы, передавая агенту преамбулу продолжения и актуальный PROGRESSDUMP (#226).
66
+ - Фильтрация structural noise в проверках dirtyFiles: в checkWorktreeDirty добавлен флаг --ignore-submodules=all, исключающий ложные блокировки удаления воркдеревьев из-за субмодулей и дрейфа gitlinks (#213).
67
+ - Фильтрация сессий субагентов: сессии с маркером subagent (origin === 'subagent' или parentId !== undefined) исключаются из liveSessions и не перезаписывают состояние корневых задач (#215).
@@ -0,0 +1,25 @@
1
+ # План реализации: Human-in-the-Loop Quality & Acceptance Gateway (#205–#209)
2
+
3
+ Пакетная реализация сквозного контура контроля качества и человеческой приёмки для `@goodandready/dsh-kanban`:
4
+ - **#205**: DoD Acceptance Checklist с фиксацией доказательств (`board_checklist`)
5
+ - **#206**: Структурированный отчёт выполнения задачи (`board_report`)
6
+ - **#207**: Двусторонний цикл приёмки человеком (Accept / Send back with reason)
7
+ - **#208**: Ветка комментариев к задаче и синхронизация с агентом (`board_comments`, `board_comment_add`)
8
+ - **#209**: Протокольный шлюз запрета перевода задачи в Done агентом
9
+
10
+ ## 1. Архитектурные требования
11
+ 1. **Протокольный шлюз Done (#209)**: агент через инструмент `board_move` не может перевести задачу в `done`. Статус `done` может быть установлен только человеком.
12
+ 2. **Шлюз перехода в Review (#205)**: если в задаче заданы обязательные пункты Definition of Done, переход в `review` блокируется до закрытия всех обязательных пунктов с предоставлением evidence.
13
+ 3. **Отчёт выполнения (#206)**: агент формирует структурированный отчёт (`board_report`) с полями `summary`, `changed_files`, `checks_run`, `artifacts`, `risks`.
14
+ 4. **Приёмка и возврат (#207)**: в колонке `review` человек может нажать «Принять» (в `done`) или «Вернуть» (в `in-progress`) с обязательным указанием причины, которая инжектируется в следующую сессию агента.
15
+ 5. **Обсуждение задачи (#208)**: карточка содержит тред комментариев с поддержкой ролей `user` и `agent`, доступный через HTTP API и инструменты `board_comments`, `board_comment_add`.
16
+
17
+ ## 2. Пошаговые задачи
18
+ - [ ] 1. Модель данных и миграции SQLite в `lib/store.js`.
19
+ - [ ] 2. Инструменты агента `board_checklist`, `board_report`, `board_comments`, `board_comment_add`, валидаторы `board_move` в `lib/board-tool.js`.
20
+ - [ ] 3. Регистрация инструментов в `lib/index.js`.
21
+ - [ ] 4. Маршруты `/accept`, `/reject`, `/checklist`, `/report`, `/comments` в `lib/routes.js`.
22
+ - [ ] 5. Инъекция замечаний возврата и DoD в `lib/launcher.js`.
23
+ - [ ] 6. Пользовательский интерфейс Task Drawer и карточек в `lib/client.js`.
24
+ - [ ] 7. Юнит-тесты: `test/checklist.test.mjs`, `test/report.test.mjs`, `test/acceptance.test.mjs`, `test/comments.test.mjs`.
25
+ - [ ] 8. Актуализация `docs/design/DESIGN.md`.
@@ -0,0 +1,33 @@
1
+ # План: Вектор 1 — Стабильность сессий и воркдеревьев (#227, #226, #213, #215)
2
+
3
+ ## 1. Контекст и цели
4
+ Повышение отказоустойчивости жизненного цикла сессий задач в DSH Kanban:
5
+ - Сохранение привязки сессии к проекту в левом сайдбаре DSH (meta.cwd = projectRoot) при изоляции в воркдереве (#227).
6
+ - Возможность продолжить работу над задачей («↻ Продолжить / Resume») в существующем воркдереве без деструктивного сброса ветки (#226).
7
+ - Игнорирование структурного шума субмодулей и gitlink drift в проверках чистоты воркдерева checkWorktreeDirty (#213).
8
+ - Исключение дочерних сессий субагентов (invoke_subagent) из списка живых сессий и трекинга карточек (#215).
9
+
10
+ ## 2. Архитектурные изменения
11
+ - **lib/worktree.js**:
12
+ - checkWorktreeDirty: вызов gitRunner(['status', '--porcelain', '--ignore-submodules=all'], worktreePath) для устранения ложного dirty-статуса от субмодулей.
13
+ - **lib/launcher.js**:
14
+ - obtainAgent: meta.cwd фиксируется как projectRoot, meta.worktreePath = worktreePath.
15
+ -
16
+ - liveSessions: фильтрация сессий с origin === 'subagent' или parentId !== undefined.
17
+ - **lib/lifecycle.js**:
18
+ - handleSessionEvent: игнорирование событий от сессий-субагентов.
19
+ - **lib/routes.js**:
20
+ - Эндпоинт POST /tasks/:id/resume.
21
+ - **lib/index.js**:
22
+ - Регистрация маршрута /dsh-kanban/tasks/:id/resume.
23
+ - **lib/client.js**:
24
+ - Кнопка «↻ Resume» в TaskDrawer.
25
+
26
+ ## 3. Верификация
27
+ - Юнит-тесты: est/worktree-dirty.test.mjs, est/launcher-cwd.test.mjs, est/resume.test.mjs, est/subagent-filter.test.mjs.
28
+ - Полный прогон
29
+ pm test и
30
+ pm run compat.
31
+ - Проверка пакета
32
+ pm pack --dry-run --json.
33
+ - Тест на MiniPC test runner через dsh-test-plugin.
package/lib/board-tool.js CHANGED
@@ -111,9 +111,19 @@ export function boardMoveDefinition({ store }) {
111
111
  const task = picked.task
112
112
  // Перечисление уже не предлагает `done`, но модель может прислать его
113
113
  // мимо схемы, а завершение задачи не должно опираться на заявление.
114
- if (TOOL_FORBIDDEN_COLUMNS.includes(column)) {
115
- return 'A task is not finished by moving its card. Close the issue and delete the '
116
- + 'branch; the card reaches done once the board sees that.'
114
+ if (column === 'done' || TOOL_FORBIDDEN_COLUMNS.includes(column)) {
115
+ return 'The done column is a human-only acceptance boundary. A task is not finished by moving its card: move it to review for human acceptance; only a human can accept and move it to done.'
116
+ }
117
+ if (column === 'review') {
118
+ const checklist = Array.isArray(task.checklist) ? task.checklist : []
119
+ const pendingRequired = checklist.filter((item) => item.required !== false && !item.completed)
120
+ if (pendingRequired.length > 0) {
121
+ const totalReq = checklist.filter((i) => i.required !== false).length
122
+ const doneReq = totalReq - pendingRequired.length
123
+ return `Cannot move card to review: uncompleted required DoD checklist items (${doneReq}/${totalReq} completed). `
124
+ + `Pending: ${pendingRequired.map((i) => i.text).join('; ')}. `
125
+ + 'Use `board_checklist` with verification evidence to complete required items.'
126
+ }
117
127
  }
118
128
  const move = resolveTransition(task.column, [{ column, source: 'tool', detail: detail ?? '' }])
119
129
  if (move === undefined) return `The card is already in ${task.column}.`
@@ -176,3 +186,175 @@ export function boardPlanDefinition({ store }) {
176
186
  },
177
187
  }
178
188
  }
189
+
190
+
191
+ /** Определение board_checklist (#205) */
192
+ export function boardChecklistDefinition({ store }) {
193
+ return {
194
+ name: 'board_checklist',
195
+ description: 'Mark a Definition of Done (DoD) checklist item as completed with verification evidence.',
196
+ parameters: {
197
+ item: {
198
+ type: 'number',
199
+ description: '1-based index of the checklist item (e.g. 1, 2, 3).',
200
+ required: true,
201
+ },
202
+ evidence: {
203
+ type: 'string',
204
+ description: 'Proof of completion (test command output, verified commit hash, file path, etc.).',
205
+ required: true,
206
+ },
207
+ task: {
208
+ type: 'string',
209
+ description: 'Task reference (`repo#number` or card id). Required if this session carries multiple tasks.',
210
+ },
211
+ },
212
+ output: {
213
+ schema: { type: 'string', description: 'Checklist update status.' },
214
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
215
+ },
216
+ async execute({ item, evidence, task: ref }, exec) {
217
+ const picked = pickTask(store, exec, ref)
218
+ if (picked.error !== undefined) return picked.error
219
+ const task = picked.task
220
+ const checklist = Array.isArray(task.checklist) ? [...task.checklist] : []
221
+ if (checklist.length === 0) return 'This task has no DoD checklist configured.'
222
+ const num = Number(item)
223
+ const idx = num - 1
224
+ if (!Number.isInteger(num) || idx < 0 || idx >= checklist.length) {
225
+ return `Invalid item ${item}. Task has ${checklist.length} checklist items (1..${checklist.length}).`
226
+ }
227
+ const ev = String(evidence ?? '').trim()
228
+ if (ev === '') return 'Evidence is required when completing a DoD item.'
229
+ checklist[idx] = {
230
+ ...checklist[idx],
231
+ completed: true,
232
+ evidence: ev,
233
+ completedAt: Date.now(),
234
+ }
235
+ store.updateTask(task.id, { checklist })
236
+ const doneCount = checklist.filter((i) => i.completed).length
237
+ return `DoD item ${num} ("${checklist[idx].text}") marked as completed. Progress: ${doneCount}/${checklist.length}.`
238
+ },
239
+ }
240
+ }
241
+
242
+ /** Определение board_report (#206) */
243
+ export function boardReportDefinition({ store }) {
244
+ return {
245
+ name: 'board_report',
246
+ description: 'Publish a structured execution report before moving the card to review.',
247
+ parameters: {
248
+ summary: {
249
+ type: 'string',
250
+ description: 'Executive summary of the completed work.',
251
+ required: true,
252
+ },
253
+ changed_files: {
254
+ type: 'array',
255
+ items: { type: 'string' },
256
+ description: 'List of created, modified, or deleted files.',
257
+ },
258
+ checks_run: {
259
+ type: 'array',
260
+ items: { type: 'string' },
261
+ description: 'Commands executed and verification results.',
262
+ },
263
+ artifacts: {
264
+ type: 'array',
265
+ items: { type: 'string' },
266
+ description: 'Artifact links, tarballs, PR URLs, or commit hashes.',
267
+ },
268
+ risks: {
269
+ type: 'array',
270
+ items: { type: 'string' },
271
+ description: 'Remaining risks, constraints, or caveats.',
272
+ },
273
+ task: {
274
+ type: 'string',
275
+ description: 'Task reference (`repo#number` or card id). Required if this session carries multiple tasks.',
276
+ },
277
+ },
278
+ output: {
279
+ schema: { type: 'string', description: 'Report status confirmation.' },
280
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
281
+ },
282
+ async execute({ summary, changed_files, checks_run, artifacts, risks, task: ref }, exec) {
283
+ const picked = pickTask(store, exec, ref)
284
+ if (picked.error !== undefined) return picked.error
285
+ const task = picked.task
286
+ const s = String(summary ?? '').trim()
287
+ if (s === '') return 'A summary is required in board_report.'
288
+ const report = {
289
+ summary: s,
290
+ changedFiles: Array.isArray(changed_files) ? changed_files : [],
291
+ checksRun: Array.isArray(checks_run) ? checks_run : [],
292
+ artifacts: Array.isArray(artifacts) ? artifacts : [],
293
+ risks: Array.isArray(risks) ? risks : [],
294
+ createdAt: Date.now(),
295
+ }
296
+ store.setReport(task.id, report)
297
+ return `Execution report for task "${task.title}" saved successfully.`
298
+ },
299
+ }
300
+ }
301
+
302
+ /** Определение board_comments (#208) */
303
+ export function boardCommentsDefinition({ store }) {
304
+ return {
305
+ name: 'board_comments',
306
+ description: 'Read the comments and discussion thread for the current task.',
307
+ parameters: {
308
+ task: {
309
+ type: 'string',
310
+ description: 'Task reference (`repo#number` or card id). Required if this session carries multiple tasks.',
311
+ },
312
+ },
313
+ output: {
314
+ schema: { type: 'string', description: 'Discussion thread text.' },
315
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
316
+ },
317
+ async execute({ task: ref }, exec) {
318
+ const picked = pickTask(store, exec, ref)
319
+ if (picked.error !== undefined) return picked.error
320
+ const task = picked.task
321
+ const comments = Array.isArray(task.comments) ? task.comments : []
322
+ if (comments.length === 0) return 'No comments on this task yet.'
323
+ return comments
324
+ .map((c, i) => `#${i + 1} [${c.role === 'agent' ? 'Agent' : 'User'}] (${new Date(c.createdAt).toLocaleTimeString()}): ${c.text}`)
325
+ .join('\n\n')
326
+ },
327
+ }
328
+ }
329
+
330
+ /** Определение board_comment_add (#208) */
331
+ export function boardCommentAddDefinition({ store }) {
332
+ return {
333
+ name: 'board_comment_add',
334
+ description: 'Post a comment to the task discussion thread.',
335
+ parameters: {
336
+ text: {
337
+ type: 'string',
338
+ description: 'Comment message text.',
339
+ required: true,
340
+ },
341
+ task: {
342
+ type: 'string',
343
+ description: 'Task reference (`repo#number` or card id). Required if this session carries multiple tasks.',
344
+ },
345
+ },
346
+ output: {
347
+ schema: { type: 'string', description: 'Status of posting comment.' },
348
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
349
+ },
350
+ async execute({ text, task: ref }, exec) {
351
+ const picked = pickTask(store, exec, ref)
352
+ if (picked.error !== undefined) return picked.error
353
+ const task = picked.task
354
+ const t = String(text ?? '').trim()
355
+ if (t === '') return 'Comment text cannot be empty.'
356
+ store.addComment(task.id, { author: 'agent', role: 'agent', text: t })
357
+ return `Comment posted to task "${task.title}".`
358
+ },
359
+ }
360
+ }
package/lib/client.js CHANGED
@@ -1095,6 +1095,7 @@ window.__ModuleLoader__.load({
1095
1095
  'panel.workDirHint': 'The project root is not set in settings, so this folder is used.',
1096
1096
  'panel.worktree': 'Worktree: {path}',
1097
1097
  'panel.cleanupWorktree': 'Clean Worktree',
1098
+ 'action.resume': '↻ Resume',
1098
1099
  'panel.worktreeDirty': 'Uncommitted changes in worktree: {files}',
1099
1100
  'panel.priority': 'Priority',
1100
1101
  'panel.priorityNone': 'No priority',
@@ -1118,6 +1119,33 @@ window.__ModuleLoader__.load({
1118
1119
  'error.giteaUnconfigured': 'Configure Gitea in the Gitea plugin card first.',
1119
1120
  'error.taskNotFound': 'Task not found.',
1120
1121
  'error.taskHasNoIssue': 'This task has no Gitea issue behind it.',
1122
+ 'checklist.title': 'Definition of Done (DoD)',
1123
+ 'checklist.empty': 'No DoD items configured yet.',
1124
+ 'checklist.add': 'Add',
1125
+ 'checklist.placeholder': 'New DoD criterion...',
1126
+ 'checklist.progress': 'DoD: {done}/{total}',
1127
+ 'checklist.required': 'Required',
1128
+ 'checklist.evidence': 'Evidence: {text}',
1129
+ 'report.title': 'Execution Report',
1130
+ 'report.summary': 'Summary',
1131
+ 'report.changedFiles': 'Changed files',
1132
+ 'report.checksRun': 'Verification checks',
1133
+ 'report.artifacts': 'Artifacts',
1134
+ 'report.risks': 'Risks & notes',
1135
+ 'acceptance.title': 'Review Acceptance Gate',
1136
+ 'acceptance.accept': 'Accept (Done)',
1137
+ 'acceptance.reject': 'Send back for rework',
1138
+ 'acceptance.rejectTitle': 'Return to work',
1139
+ 'acceptance.rejectReasonPlaceholder': 'State what needs to be fixed or improved...',
1140
+ 'acceptance.rejectSubmit': 'Send back',
1141
+ 'acceptance.rejectedBadge': 'Rework',
1142
+ 'acceptance.rejectedAlert': 'Returned for rework: {reason}',
1143
+ 'comments.title': 'Discussion & Comments',
1144
+ 'comments.empty': 'No comments yet.',
1145
+ 'comments.add': 'Post comment',
1146
+ 'comments.placeholder': 'Add a note or feedback...',
1147
+ 'comments.authorUser': 'User',
1148
+ 'comments.authorAgent': 'Agent',
1121
1149
  'error.issueNotFound': 'Issue not found.',
1122
1150
  'error.titleRequired': 'A title is required.',
1123
1151
  'error.crossSite': 'Request rejected as cross-site.',
@@ -1389,6 +1417,7 @@ window.__ModuleLoader__.load({
1389
1417
  'panel.workDirHint': '设置中未指定项目根目录,因此使用此目录。',
1390
1418
  'panel.worktree': '工作区:{path}',
1391
1419
  'panel.cleanupWorktree': '清理工作区',
1420
+ 'action.resume': '↻ 继续执行',
1392
1421
  'panel.worktreeDirty': '工作区中存在未提交的更改:{files}',
1393
1422
  'panel.priority': '优先级',
1394
1423
  'panel.priorityNone': '无优先级',
@@ -1412,6 +1441,33 @@ window.__ModuleLoader__.load({
1412
1441
  'error.giteaUnconfigured': '请先在 Gitea 插件的设置卡片中完成配置。',
1413
1442
  'error.taskNotFound': '没有找到任务。',
1414
1443
  'error.taskHasNoIssue': '该任务背后没有 Gitea issue。',
1444
+ 'checklist.title': '完成定义 (DoD)',
1445
+ 'checklist.empty': '尚未配置 DoD 检查项。',
1446
+ 'checklist.add': '添加',
1447
+ 'checklist.placeholder': '新的 DoD 验收标准...',
1448
+ 'checklist.progress': 'DoD: {done}/{total}',
1449
+ 'checklist.required': '必填',
1450
+ 'checklist.evidence': '证明: {text}',
1451
+ 'report.title': '执行报告',
1452
+ 'report.summary': '概述',
1453
+ 'report.changedFiles': '变更文件',
1454
+ 'report.checksRun': '验证检查',
1455
+ 'report.artifacts': '生成产物',
1456
+ 'report.risks': '风险与说明',
1457
+ 'acceptance.title': '评审验收网关',
1458
+ 'acceptance.accept': '验收通过 (完成)',
1459
+ 'acceptance.reject': '退回重做',
1460
+ 'acceptance.rejectTitle': '退回修改',
1461
+ 'acceptance.rejectReasonPlaceholder': '说明需要修改或补充的问题...',
1462
+ 'acceptance.rejectSubmit': '确认退回',
1463
+ 'acceptance.rejectedBadge': '需重做',
1464
+ 'acceptance.rejectedAlert': '退回重做说明: {reason}',
1465
+ 'comments.title': '讨论与评论',
1466
+ 'comments.empty': '暂无评论。',
1467
+ 'comments.add': '发表评论',
1468
+ 'comments.placeholder': '添加说明或反馈...',
1469
+ 'comments.authorUser': '用户',
1470
+ 'comments.authorAgent': 'Agent',
1415
1471
  'error.issueNotFound': '没有找到 issue。',
1416
1472
  'error.titleRequired': '标题不能为空。',
1417
1473
  'error.crossSite': '请求被判定为跨站而拒绝。',
@@ -1680,6 +1736,16 @@ window.__ModuleLoader__.load({
1680
1736
  taskRef(task, t),
1681
1737
  task.cron ? React.createElement('span', { className: 'dkb-tag', title: task.cron }, '⏰ cron') : null,
1682
1738
  task.progressDump ? React.createElement('span', { className: 'dkb-tag', title: 'PROGRESSDUMP' }, '📋 dump') : null,
1739
+ task.rejectReason ? React.createElement('span', { className: 'dkb-tag', style: { background: 'var(--dsw-alias-state-danger-bg, #ffebe9)', color: 'var(--dsw-alias-state-danger-primary, #cf222e)', borderColor: 'var(--dsw-alias-state-danger-border, #ffc1c0)' }, title: task.rejectReason }, '↩ ' + t('acceptance.rejectedBadge')) : null,
1740
+ Array.isArray(task.checklist) && task.checklist.length > 0
1741
+ ? React.createElement('span', { className: 'dkb-tag', title: 'DoD Checklist' }, `☑ ${task.checklist.filter((i) => i.completed).length}/${task.checklist.length}`)
1742
+ : null,
1743
+ task.report && task.report.summary
1744
+ ? React.createElement('span', { className: 'dkb-tag', title: 'Execution Report' }, '📄 report')
1745
+ : null,
1746
+ Array.isArray(task.comments) && task.comments.length > 0
1747
+ ? React.createElement('span', { className: 'dkb-tag', title: 'Comments' }, `💬 ${task.comments.length}`)
1748
+ : null,
1683
1749
  (task.labels || []).map((l) => React.createElement('span', {
1684
1750
  className: 'dkb-tag', key: l, style: tagStyle(task, l),
1685
1751
  }, l))),
@@ -1824,6 +1890,10 @@ window.__ModuleLoader__.load({
1824
1890
  const [state, setState] = React.useState(null)
1825
1891
  const [error, setError] = React.useState('')
1826
1892
  const [openTask, setOpenTask] = React.useState(null)
1893
+ const [rejectModalOpen, setRejectModalOpen] = React.useState(false)
1894
+ const [rejectReasonText, setRejectReasonText] = React.useState('')
1895
+ const [newChecklistText, setNewChecklistText] = React.useState('')
1896
+ const [newCommentText, setNewCommentText] = React.useState('')
1827
1897
  const [log, setLog] = React.useState([])
1828
1898
  const [dialog, setDialog] = React.useState(null)
1829
1899
  // Черновик правки приоритета и меток в окне задачи. Живёт отдельно от
@@ -2760,6 +2830,280 @@ window.__ModuleLoader__.load({
2760
2830
  ))),
2761
2831
  )
2762
2832
  : null,
2833
+ openTask.rejectReason
2834
+ ? React.createElement('div', {
2835
+ className: 'dkb-warnBox',
2836
+ style: {
2837
+ margin: '4px 0', padding: 10,
2838
+ background: 'var(--dsw-alias-state-danger-bg, #ffebe9)',
2839
+ border: '1px solid var(--dsw-alias-state-danger-border, #ffc1c0)',
2840
+ borderRadius: 6,
2841
+ },
2842
+ },
2843
+ React.createElement('div', {
2844
+ style: { marginBottom: 4, fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-state-danger-primary, #cf222e)' },
2845
+ }, '⚠️ ' + t('acceptance.rejectedAlert', { reason: openTask.rejectReason })),
2846
+ )
2847
+ : null,
2848
+ openTask.column === 'review'
2849
+ ? React.createElement('div', {
2850
+ style: {
2851
+ margin: '8px 0', padding: 12,
2852
+ background: 'var(--dsw-alias-bg-layer-3)',
2853
+ border: '1px solid var(--dsw-alias-border-l2)',
2854
+ borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 8,
2855
+ },
2856
+ },
2857
+ React.createElement('div', { className: 'dkb-group', style: { margin: 0 } }, t('acceptance.title')),
2858
+ React.createElement('div', { style: { display: 'flex', gap: 8 } },
2859
+ React.createElement('button', {
2860
+ type: 'button', className: 'dkb-save',
2861
+ onClick: async () => {
2862
+ try {
2863
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/accept', { method: 'POST', body: '{}' })
2864
+ if (out && out.task) setOpenTask(out.task)
2865
+ await load(board)
2866
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2867
+ },
2868
+ }, '✓ ' + t('acceptance.accept')),
2869
+ React.createElement('button', {
2870
+ type: 'button', className: 'dkb-discard',
2871
+ onClick: () => { setRejectModalOpen(true); setRejectReasonText('') },
2872
+ }, '✗ ' + t('acceptance.reject')),
2873
+ ),
2874
+ rejectModalOpen
2875
+ ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 } },
2876
+ React.createElement('span', { className: 'dkb-label' }, t('acceptance.rejectTitle')),
2877
+ React.createElement('textarea', {
2878
+ className: 'dkb-area',
2879
+ value: rejectReasonText,
2880
+ placeholder: t('acceptance.rejectReasonPlaceholder'),
2881
+ onChange: (e) => setRejectReasonText(e.target.value),
2882
+ rows: 3,
2883
+ }),
2884
+ React.createElement('div', { style: { display: 'flex', gap: 6 } },
2885
+ React.createElement('button', {
2886
+ type: 'button', className: 'dkb-save',
2887
+ onClick: async () => {
2888
+ if (!rejectReasonText.trim()) return
2889
+ try {
2890
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/reject', {
2891
+ method: 'POST',
2892
+ body: JSON.stringify({ reason: rejectReasonText.trim() }),
2893
+ })
2894
+ if (out && out.task) setOpenTask(out.task)
2895
+ setRejectModalOpen(false)
2896
+ await load(board)
2897
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2898
+ },
2899
+ }, t('acceptance.rejectSubmit')),
2900
+ React.createElement('button', {
2901
+ type: 'button', className: 'dkb-discard',
2902
+ onClick: () => setRejectModalOpen(false),
2903
+ }, t('dialog.cancel')),
2904
+ ),
2905
+ )
2906
+ : null,
2907
+ )
2908
+ : null,
2909
+ React.createElement('div', { style: { margin: '4px 0' } },
2910
+ React.createElement('div', { className: 'dkb-group', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
2911
+ React.createElement('span', null, t('checklist.title')),
2912
+ Array.isArray(openTask.checklist) && openTask.checklist.length > 0
2913
+ ? React.createElement('span', { className: 'dkb-hint', style: { fontWeight: 'normal' } },
2914
+ t('checklist.progress', {
2915
+ done: openTask.checklist.filter((i) => i.completed).length,
2916
+ total: openTask.checklist.length,
2917
+ }))
2918
+ : null,
2919
+ ),
2920
+ Array.isArray(openTask.checklist) && openTask.checklist.length > 0
2921
+ ? React.createElement('ul', { style: { listStyle: 'none', padding: 0, margin: '4px 0', display: 'flex', flexDirection: 'column', gap: 4 } },
2922
+ openTask.checklist.map((item, idx) => React.createElement('li', {
2923
+ key: idx,
2924
+ style: {
2925
+ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 8px',
2926
+ background: item.completed ? 'var(--dsw-alias-bg-layer-1)' : 'var(--dsw-alias-bg-layer-3)',
2927
+ borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)',
2928
+ },
2929
+ },
2930
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
2931
+ React.createElement('input', {
2932
+ type: 'checkbox',
2933
+ checked: Boolean(item.completed),
2934
+ onChange: async (e) => {
2935
+ const updated = [...openTask.checklist]
2936
+ updated[idx] = { ...updated[idx], completed: e.target.checked }
2937
+ try {
2938
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/checklist', {
2939
+ method: 'POST',
2940
+ body: JSON.stringify({ checklist: updated }),
2941
+ })
2942
+ if (out && out.task) setOpenTask(out.task)
2943
+ await load(board)
2944
+ } catch (err) { setError(t(err.key || 'error.unknown')) }
2945
+ },
2946
+ }),
2947
+ React.createElement('span', {
2948
+ style: {
2949
+ textDecoration: item.completed ? 'line-through' : 'none',
2950
+ color: item.completed ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-label-primary)',
2951
+ fontSize: 13, flex: 1,
2952
+ },
2953
+ }, item.text),
2954
+ item.required !== false
2955
+ ? React.createElement('span', { className: 'dkb-tag', style: { fontSize: 10 } }, t('checklist.required'))
2956
+ : null,
2957
+ ),
2958
+ item.evidence
2959
+ ? React.createElement('div', {
2960
+ style: {
2961
+ fontSize: 11, color: 'var(--dsw-alias-label-secondary)', marginLeft: 24,
2962
+ fontFamily: 'var(--dsw-font-family-mono, monospace)', whiteSpace: 'pre-wrap',
2963
+ },
2964
+ }, t('checklist.evidence', { text: item.evidence }))
2965
+ : null,
2966
+ )))
2967
+ : React.createElement('p', { className: 'dkb-hint' }, t('checklist.empty')),
2968
+ React.createElement('div', { style: { display: 'flex', gap: 6, marginTop: 4 } },
2969
+ React.createElement('input', {
2970
+ className: 'dkb-input',
2971
+ style: { flex: 1 },
2972
+ placeholder: t('checklist.placeholder'),
2973
+ value: newChecklistText,
2974
+ onChange: (e) => setNewChecklistText(e.target.value),
2975
+ onKeyDown: async (e) => {
2976
+ if (e.key === 'Enter' && newChecklistText.trim()) {
2977
+ e.preventDefault()
2978
+ const updated = [...(openTask.checklist || []), { text: newChecklistText.trim(), required: true, completed: false }]
2979
+ setNewChecklistText('')
2980
+ try {
2981
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/checklist', {
2982
+ method: 'POST',
2983
+ body: JSON.stringify({ checklist: updated }),
2984
+ })
2985
+ if (out && out.task) setOpenTask(out.task)
2986
+ await load(board)
2987
+ } catch (err) { setError(t(err.key || 'error.unknown')) }
2988
+ }
2989
+ },
2990
+ }),
2991
+ React.createElement('button', {
2992
+ type: 'button', className: 'dkb-save',
2993
+ onClick: async () => {
2994
+ if (!newChecklistText.trim()) return
2995
+ const updated = [...(openTask.checklist || []), { text: newChecklistText.trim(), required: true, completed: false }]
2996
+ setNewChecklistText('')
2997
+ try {
2998
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/checklist', {
2999
+ method: 'POST',
3000
+ body: JSON.stringify({ checklist: updated }),
3001
+ })
3002
+ if (out && out.task) setOpenTask(out.task)
3003
+ await load(board)
3004
+ } catch (err) { setError(t(err.key || 'error.unknown')) }
3005
+ },
3006
+ }, t('checklist.add')),
3007
+ ),
3008
+ ),
3009
+ openTask.report && openTask.report.summary
3010
+ ? React.createElement('div', {
3011
+ style: {
3012
+ margin: '6px 0', padding: 10,
3013
+ background: 'var(--dsw-alias-bg-layer-1)',
3014
+ border: '1px solid var(--dsw-alias-border-l2)',
3015
+ borderRadius: 6,
3016
+ },
3017
+ },
3018
+ React.createElement('div', { className: 'dkb-group', style: { margin: '0 0 6px 0' } }, t('report.title')),
3019
+ React.createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-primary)', marginBottom: 6, fontWeight: 500 } },
3020
+ openTask.report.summary),
3021
+ openTask.report.changedFiles && openTask.report.changedFiles.length
3022
+ ? React.createElement('div', { style: { fontSize: 12, marginBottom: 4 } },
3023
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('report.changedFiles') + ': '),
3024
+ openTask.report.changedFiles.map((f) => React.createElement('span', { className: 'dkb-tag', key: f, style: { margin: '1px 2px', fontFamily: 'monospace' } }, f)),
3025
+ )
3026
+ : null,
3027
+ openTask.report.checksRun && openTask.report.checksRun.length
3028
+ ? React.createElement('div', { style: { fontSize: 12, marginBottom: 4 } },
3029
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('report.checksRun') + ': '),
3030
+ openTask.report.checksRun.map((chk, i) => React.createElement('div', { key: i, style: { fontFamily: 'monospace', fontSize: 11, marginLeft: 8 } }, '• ' + chk)),
3031
+ )
3032
+ : null,
3033
+ openTask.report.artifacts && openTask.report.artifacts.length
3034
+ ? React.createElement('div', { style: { fontSize: 12, marginBottom: 4 } },
3035
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('report.artifacts') + ': '),
3036
+ openTask.report.artifacts.map((a, i) => React.createElement('span', { className: 'dkb-tag', key: i, style: { margin: '1px 2px' } }, a)),
3037
+ )
3038
+ : null,
3039
+ openTask.report.risks && openTask.report.risks.length
3040
+ ? React.createElement('div', { style: { fontSize: 12 } },
3041
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('report.risks') + ': '),
3042
+ openTask.report.risks.map((r, i) => React.createElement('div', { key: i, style: { fontSize: 11, color: 'var(--dsw-alias-state-warn-primary, #b08800)', marginLeft: 8 } }, '⚠ ' + r)),
3043
+ )
3044
+ : null,
3045
+ )
3046
+ : null,
3047
+ React.createElement('div', { style: { margin: '6px 0' } },
3048
+ React.createElement('div', { className: 'dkb-group', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
3049
+ React.createElement('span', null, t('comments.title')),
3050
+ Array.isArray(openTask.comments) && openTask.comments.length > 0
3051
+ ? React.createElement('span', { className: 'dkb-hint' }, `(${openTask.comments.length})`)
3052
+ : null,
3053
+ ),
3054
+ Array.isArray(openTask.comments) && openTask.comments.length > 0
3055
+ ? React.createElement('ul', {
3056
+ style: {
3057
+ listStyle: 'none', padding: 0, margin: '4px 0', maxHeight: 200, overflowY: 'auto',
3058
+ display: 'flex', flexDirection: 'column', gap: 6,
3059
+ },
3060
+ },
3061
+ openTask.comments.map((c, i) => React.createElement('li', {
3062
+ key: c.id || i,
3063
+ style: {
3064
+ padding: 8, background: 'var(--dsw-alias-bg-layer-3)',
3065
+ borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', fontSize: 12,
3066
+ },
3067
+ },
3068
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', marginBottom: 4 } },
3069
+ React.createElement('span', {
3070
+ className: 'dkb-tag',
3071
+ style: {
3072
+ fontWeight: 600,
3073
+ background: c.role === 'agent' ? 'var(--dsw-alias-bg-layer-1)' : 'var(--dsw-alias-bg-layer-2)',
3074
+ },
3075
+ }, c.role === 'agent' ? t('comments.authorAgent') : (c.author || t('comments.authorUser'))),
3076
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 11 } },
3077
+ new Date(c.createdAt || Date.now()).toLocaleTimeString()),
3078
+ ),
3079
+ React.createElement('div', { style: { whiteSpace: 'pre-wrap', color: 'var(--dsw-alias-label-primary)' } }, c.text),
3080
+ )))
3081
+ : React.createElement('p', { className: 'dkb-hint' }, t('comments.empty')),
3082
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, marginTop: 4 } },
3083
+ React.createElement('textarea', {
3084
+ className: 'dkb-area',
3085
+ placeholder: t('comments.placeholder'),
3086
+ value: newCommentText,
3087
+ rows: 2,
3088
+ onChange: (e) => setNewCommentText(e.target.value),
3089
+ }),
3090
+ React.createElement('button', {
3091
+ type: 'button', className: 'dkb-save', style: { alignSelf: 'flex-start' },
3092
+ onClick: async () => {
3093
+ if (!newCommentText.trim()) return
3094
+ try {
3095
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/comments', {
3096
+ method: 'POST',
3097
+ body: JSON.stringify({ text: newCommentText.trim(), role: 'user', author: 'user' }),
3098
+ })
3099
+ if (out && out.task) setOpenTask(out.task)
3100
+ setNewCommentText('')
3101
+ await load(board)
3102
+ } catch (err) { setError(t(err.key || 'error.unknown')) }
3103
+ },
3104
+ }, t('comments.add')),
3105
+ ),
3106
+ ),
2763
3107
  openTask.archivedAt
2764
3108
  ? React.createElement('button', {
2765
3109
  type: 'button', className: 'dkb-save', style: { alignSelf: 'flex-start' },
@@ -2879,6 +3223,33 @@ window.__ModuleLoader__.load({
2879
3223
  }, t('panel.cleanupWorktree'))
2880
3224
  : null,
2881
3225
  ),
3226
+ (openTask.worktree || openTask.rejectReason || (Array.isArray(openTask.runs) && openTask.runs.length > 0)) ? React.createElement('button', {
3227
+ type: 'button',
3228
+ className: 'dkb-secondary',
3229
+ style: { marginRight: '8px', border: '1px solid var(--dsw-alias-border-color-primary, #444)' },
3230
+ disabled: starting || model === '',
3231
+ onClick: async () => {
3232
+ setStarting(true)
3233
+ try {
3234
+ rememberLaunch(board, { provider, model, agentPreset, permission })
3235
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/resume', {
3236
+ method: 'POST',
3237
+ body: JSON.stringify({ provider, model, text: draftText, agentPreset, permission }),
3238
+ })
3239
+ setOpenTask(null)
3240
+ await load(board)
3241
+ if (!openSession(props.ctx, out && out.sessionId)) {
3242
+ setError(t('error.sessionNotOpened', { id: (out && out.sessionId) || '' }))
3243
+ return
3244
+ }
3245
+ if (props.onClose) props.onClose()
3246
+ } catch (e) {
3247
+ setError(t(e.key || 'error.unknown'))
3248
+ } finally {
3249
+ setStarting(false)
3250
+ }
3251
+ },
3252
+ }, t('action.resume')) : null,
2882
3253
  React.createElement('button', {
2883
3254
  type: 'button', className: 'dkb-save', disabled: starting || model === '',
2884
3255
  onClick: async () => {
package/lib/index.js CHANGED
@@ -37,7 +37,14 @@ import { createGiteaClient } from './gitea.js'
37
37
  import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
38
38
  import { isWatched } from './intake.js'
39
39
  import { handleSessionEvent } from './lifecycle.js'
40
- import { boardMoveDefinition, boardPlanDefinition } from './board-tool.js'
40
+ import {
41
+ boardMoveDefinition,
42
+ boardPlanDefinition,
43
+ boardChecklistDefinition,
44
+ boardReportDefinition,
45
+ boardCommentsDefinition,
46
+ boardCommentAddDefinition,
47
+ } from './board-tool.js'
41
48
  import { verifySignature, parseEvent } from './webhook.js'
42
49
  import { planOutbound, planAssign, createOutbox } from './outbound.js'
43
50
  import { computeNextRun } from './cron.js'
@@ -261,6 +268,7 @@ export function apply(ctx, config) {
261
268
  handleSessionEvent({
262
269
  store,
263
270
  sessionId: String(session?.id ?? ''),
271
+ session,
264
272
  type: event?.type,
265
273
  text,
266
274
  error: event?.error,
@@ -617,6 +625,10 @@ export function apply(ctx, config) {
617
625
  const off = [
618
626
  ctx.tools.register(defineTool(boardMoveDefinition({ store }))),
619
627
  ctx.tools.register(defineTool(boardPlanDefinition({ store }))),
628
+ ctx.tools.register(defineTool(boardChecklistDefinition({ store }))),
629
+ ctx.tools.register(defineTool(boardReportDefinition({ store }))),
630
+ ctx.tools.register(defineTool(boardCommentsDefinition({ store }))),
631
+ ctx.tools.register(defineTool(boardCommentAddDefinition({ store }))),
620
632
  ]
621
633
  return () => { for (const stop of off) stop?.() }
622
634
  }
@@ -868,6 +880,86 @@ export function apply(ctx, config) {
868
880
  files: dirtyCheck.files,
869
881
  })
870
882
  return
883
+ }
884
+ if (req.method === 'POST' && action === 'checklist') {
885
+ const task = store.getTask(id)
886
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
887
+ const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
888
+ const updated = store.updateChecklist(id, list)
889
+ reply(res, { task: updated })
890
+ return
891
+ }
892
+ if (req.method === 'POST' && action === 'report') {
893
+ const task = store.getTask(id)
894
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
895
+ const updated = store.setReport(id, input)
896
+ reply(res, { task: updated })
897
+ return
898
+ }
899
+ if (req.method === 'GET' && action === 'comments') {
900
+ const task = store.getTask(id)
901
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
902
+ reply(res, { comments: task.comments || [] })
903
+ return
904
+ }
905
+ if (req.method === 'POST' && action === 'comments') {
906
+ const task = store.getTask(id)
907
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
908
+ const author = input?.author || 'user'
909
+ const role = input?.role || 'user'
910
+ const text = input?.text || ''
911
+ const updated = store.addComment(id, { author, role, text })
912
+ reply(res, { task: updated })
913
+ return
914
+ }
915
+ if (req.method === 'POST' && action === 'accept') {
916
+ const task = store.getTask(id)
917
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
918
+ const updated = store.acceptTask(id)
919
+ reply(res, { task: updated })
920
+ return
921
+ }
922
+ if (req.method === 'POST' && action === 'reject') {
923
+ const task = store.getTask(id)
924
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
925
+ const reason = String(input?.reason || '').trim()
926
+ if (!reason) { writeJson(res, 400, { error: 'reason-required' }); return }
927
+ const updated = store.rejectTask(id, reason)
928
+ reply(res, { task: updated })
929
+ return
930
+ }
931
+ if (req.method === 'POST' && action === 'resume') {
932
+ const task = store.getTask(id)
933
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
934
+ const fallback = ctx.get('agentDefaultModel')?.currentSelection?.()
935
+ const picked = resolveModel({
936
+ requested: { model: input?.model || task.model, provider: input?.provider || task.provider },
937
+ fallback,
938
+ })
939
+ if (picked.error) { reply(res, picked); return }
940
+ const out = await runTask({
941
+ agents: ctx.agents,
942
+ store,
943
+ task,
944
+ config: getConfig(),
945
+ provider: picked.provider,
946
+ model: picked.model,
947
+ mintSessionId,
948
+ createMessage,
949
+ cwdOf,
950
+ logger: ctx.logger,
951
+ text: input?.text,
952
+ agentPreset: input?.agentPreset || task.agentPreset,
953
+ permission: input?.permission || task.permission,
954
+ permissions: ctx.permissions,
955
+ claimAs: req.headers['x-dsh-user'],
956
+ gitRunner,
957
+ workspaceRegistry: ctx.get('workspaceRegistry'),
958
+ resume: true,
959
+ })
960
+ syncPower()
961
+ reply(res, out)
962
+ return
871
963
  }
872
964
  if (req.method === 'POST' && action === 'confirm-permission') {
873
965
  reply(res, confirmPermission({ store, id }))
package/lib/launcher.js CHANGED
@@ -171,6 +171,7 @@ export async function obtainAgent({
171
171
  }
172
172
 
173
173
  let cwd = resolveCwd(task, config, cwdOf)
174
+ const projectRoot = cwd
174
175
  let worktreePath = task?.worktree || undefined
175
176
  let branchName = task?.branch || undefined
176
177
  let workspaceId = task?.workspaceId || undefined
@@ -189,7 +190,6 @@ export async function obtainAgent({
189
190
  worktreePath = created.worktreePath
190
191
  branchName = created.branchName
191
192
  worktreeCreated = created.created === true
192
- cwd = created.worktreePath
193
193
 
194
194
  if (workspaceRegistry) {
195
195
  workspaceId = await registerDshWorkspace({
@@ -202,11 +202,12 @@ export async function obtainAgent({
202
202
  } catch (err) {
203
203
  logger?.warn?.(`dsh-kanban: не удалось создать worktree для ${task.id}: ${err.message}`)
204
204
  }
205
- } else if (worktreePath) {
206
- cwd = worktreePath
207
205
  }
208
206
 
209
- const meta = { cwd }
207
+ // meta.cwd сохраняет projectRoot, чтобы DSH группировал сессию внутри проекта (#227)
208
+ const meta = { cwd: projectRoot, projectRoot }
209
+ if (worktreePath) meta.worktreePath = worktreePath
210
+ if (branchName) meta.branch = branchName
210
211
  if (typeof agentPreset === 'string' && agentPreset !== '') meta.agentPreset = agentPreset
211
212
  if (workspaceId) meta.workspaceId = workspaceId
212
213
 
@@ -293,7 +294,7 @@ export async function runTask({
293
294
  agents, store, task, config, provider, model,
294
295
  mintSessionId, createMessage, cwdOf, logger, text,
295
296
  agentPreset, permission, permissions, claimAs,
296
- gitRunner, workspaceRegistry,
297
+ gitRunner, workspaceRegistry, resume = false,
297
298
  }) {
298
299
  if (config?.permissionGateEnabled) {
299
300
  const perm = permission || task.permission || 'read-only'
@@ -328,8 +329,8 @@ export async function runTask({
328
329
  taken.agent.followup(createMessage({
329
330
  // Содержимое — МАССИВ блоков, а не строка: ядро перебирает его как список
330
331
  // (`content.some(...)`), и строка роняет ход целиком.
331
- content: [{ type: 'text', text: messageFor(task, config, text) }],
332
- source: { kind: 'plugin', plugin: 'dsh-kanban', form: 'task-start' },
332
+ content: [{ type: 'text', text: messageFor(task, config, text, { resume, mode: taken.mode }) }],
333
+ source: { kind: 'plugin', plugin: 'dsh-kanban', form: resume ? 'task-resume' : 'task-start' },
333
334
  }))
334
335
 
335
336
  const before = store.getTask(task.id)
@@ -347,6 +348,16 @@ export async function runTask({
347
348
  // остаётся чужой.
348
349
  claimOnStart({ store, task: before, login: claimAs })
349
350
 
351
+ if (resume) {
352
+ store.addTransition({
353
+ taskId: task.id,
354
+ fromCol: before.column,
355
+ toCol: before.column,
356
+ source: 'session',
357
+ detail: `продолжение работы над задачей (сессия ${taken.sessionId})`,
358
+ })
359
+ }
360
+
350
361
  // Изоляция сессии в отдельном воркдереве фиксируется в журнале.
351
362
  if (taken.worktreeCreated) {
352
363
  store.addTransition({
@@ -386,9 +397,33 @@ export async function runTask({
386
397
  }
387
398
 
388
399
  /** Текст сообщения: человеческий, если он есть, иначе встроенная заготовка. */
389
- function messageFor(task, config, text) {
400
+ export function messageFor(task, config, text, options = {}) {
390
401
  const own = typeof text === 'string' ? text.trim() : ''
391
- const base = own !== '' ? own : buildStartMessage(task, config)
402
+ let base = own !== '' ? own : buildStartMessage(task, config)
403
+ let resumePreamble = ''
404
+ if (options.resume || options.mode === 'resumed') {
405
+ const wt = task?.worktree ? ` в существующем воркдереве ${task.worktree}` : ''
406
+ const br = task?.branch ? ` на ветке ${task.branch}` : ''
407
+ resumePreamble = `### ↻ Режим продолжения работы над задачей\nРабота продолжается${wt}${br}. Сохраняй существующие изменения и продолжай выполнение с текущего состояния.\n\n`
408
+ }
409
+ let qualityNotes = ''
410
+ if (task?.rejectReason) {
411
+ qualityNotes += `### ⚠️ Внимание: Задача возвращена на доработку\nЗамечания к приёмке (причина возврата):\n${task.rejectReason}\n\n`
412
+ }
413
+ if (Array.isArray(task?.checklist) && task.checklist.length > 0) {
414
+ const uncompleted = task.checklist.filter((i) => i.required !== false && !i.completed)
415
+ if (uncompleted.length > 0) {
416
+ qualityNotes += `### Обязательные критерии готовности (DoD):\n`
417
+ + uncompleted.map((item, idx) => `${idx + 1}. [ ] ${item.text}`).join('\n')
418
+ + `\nДля фиксации выполнения используй инструмент board_checklist(item, evidence).\n\n`
419
+ }
420
+ }
421
+ if (qualityNotes) {
422
+ base = qualityNotes + base
423
+ }
424
+ if (resumePreamble) {
425
+ base = resumePreamble + base
426
+ }
392
427
  if (config?.progressDumpEnabled && task.progressDump) {
393
428
  let parsed
394
429
  try {
@@ -575,6 +610,10 @@ export function liveSessions({ store, agents }) {
575
610
  let live
576
611
  try { live = agents.get(id) } catch { live = undefined }
577
612
  if (live === undefined) continue
613
+ // Исключаем дочерние сессии субагентов (#215)
614
+ if (live.origin === 'subagent' || live.parentId !== undefined || live.meta?.origin === 'subagent' || live.isSubagent) {
615
+ continue
616
+ }
578
617
  if (!byId.has(id)) byId.set(id, { sessionId: id, status: live.status, tasks: [] })
579
618
  byId.get(id).tasks.push({
580
619
  id: task.id, title: task.title, repo: task.repo, issueNumber: task.issueNumber,
package/lib/lifecycle.js CHANGED
@@ -20,7 +20,11 @@ import { parseProgressDump, formatProgressDump } from './progress-dump.js'
20
20
  *
21
21
  * @returns {{taskId: string, waiting?: boolean}|undefined} что изменилось
22
22
  */
23
- export function handleSessionEvent({ store, sessionId, type, text, error }) {
23
+ export function handleSessionEvent({ store, sessionId, session, type, text, error }) {
24
+ // Исключаем события дочерних сессий субагентов (#215)
25
+ if (session && (session.origin === 'subagent' || session.parentId !== undefined || session.meta?.origin === 'subagent' || session.isSubagent)) {
26
+ return undefined
27
+ }
24
28
  const task = store.findTaskBySession(sessionId)
25
29
  if (task === undefined) return undefined
26
30
 
package/lib/routes.js CHANGED
@@ -29,7 +29,7 @@ export function isTrustedRequest(req) {
29
29
 
30
30
  /** Разобрать `/dsh-kanban/task/<id>[/<action>]`; иначе undefined. */
31
31
  export function parseTaskPath(pathname) {
32
- const m = /^\/dsh-kanban\/task\/([^/]+)(?:\/([a-z]+))?\/?$/.exec(pathname ?? '')
32
+ const m = /^\/dsh-kanban\/task\/([^/]+)(?:\/([a-z-]+))?\/?$/.exec(pathname ?? '')
33
33
  if (m === null) return undefined
34
34
  return { id: decodeURIComponent(m[1]), action: m[2] }
35
35
  }
@@ -203,6 +203,10 @@ export function updateTask({ store, id, input }) {
203
203
  patch.cron = input.cron.trim()
204
204
  patch.nextRunAt = patch.cron ? (computeNextRun(patch.cron) || 0) : 0
205
205
  }
206
+ if (Array.isArray(input?.checklist)) patch.checklist = input.checklist
207
+ if (input?.report && typeof input.report === 'object') patch.report = input.report
208
+ if (Array.isArray(input?.comments)) patch.comments = input.comments
209
+ if (typeof input?.rejectReason === 'string') patch.rejectReason = input.rejectReason
206
210
  if (typeof input?.progressDump === 'string') {
207
211
  patch.progressDump = input.progressDump
208
212
  }
package/lib/store.js CHANGED
@@ -64,6 +64,11 @@ CREATE TABLE IF NOT EXISTS tasks (
64
64
  dueAt INTEGER NOT NULL DEFAULT 0,
65
65
  syncedAt INTEGER NOT NULL DEFAULT 0,
66
66
  createdAt INTEGER NOT NULL,
67
+ checklist TEXT NOT NULL DEFAULT '[]',
68
+ report TEXT NOT NULL DEFAULT '{}',
69
+ comments TEXT NOT NULL DEFAULT '[]',
70
+ rejectReason TEXT NOT NULL DEFAULT '',
71
+ rejectedAt INTEGER NOT NULL DEFAULT 0,
67
72
  updatedAt INTEGER NOT NULL
68
73
  );
69
74
  CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
@@ -92,7 +97,7 @@ const WRITABLE = [
92
97
  'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
93
98
  'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
94
99
  'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
95
- 'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'permissionConfirmedAt', 'runs',
100
+ 'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'permissionConfirmedAt', 'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt',
96
101
  ]
97
102
 
98
103
  /** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
@@ -100,7 +105,22 @@ const SOURCES = new Set(['manual', 'session', 'gitea', 'tool'])
100
105
 
101
106
  function rowToTask(row) {
102
107
  if (row === undefined) return undefined
103
- const { col, labels, waiting, labelColors, runs, ...rest } = row
108
+ const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, ...rest } = row
109
+ let parsedChecklist = []
110
+ try {
111
+ const value = JSON.parse(checklist ?? '[]')
112
+ if (Array.isArray(value)) parsedChecklist = value
113
+ } catch { /* игнорируем битый json */ }
114
+ let parsedReport = {}
115
+ try {
116
+ const value = JSON.parse(report ?? '{}')
117
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) parsedReport = value
118
+ } catch { /* игнорируем битый json */ }
119
+ let parsedComments = []
120
+ try {
121
+ const value = JSON.parse(comments ?? '[]')
122
+ if (Array.isArray(value)) parsedComments = value
123
+ } catch { /* игнорируем битый json */ }
104
124
  let parsed = []
105
125
  try {
106
126
  const value = JSON.parse(labels)
@@ -116,7 +136,19 @@ function rowToTask(row) {
116
136
  const value = JSON.parse(runs ?? '[]')
117
137
  if (Array.isArray(value)) parsedRuns = value
118
138
  } catch { /* испорченное поле не должно ронять чтение всей доски */ }
119
- return { ...rest, column: col, labels: parsed, labelColors: colors, waiting: waiting === 1, runs: parsedRuns }
139
+ return {
140
+ ...rest,
141
+ column: col,
142
+ labels: parsed,
143
+ labelColors: colors,
144
+ waiting: waiting === 1,
145
+ runs: parsedRuns,
146
+ checklist: parsedChecklist,
147
+ report: parsedReport,
148
+ comments: parsedComments,
149
+ rejectReason: rejectReason ?? '',
150
+ rejectedAt: Number(rejectedAt) || 0,
151
+ }
120
152
  }
121
153
 
122
154
  /**
@@ -154,6 +186,11 @@ export function openStore({ dir }) {
154
186
  ['permission', "TEXT NOT NULL DEFAULT ''"],
155
187
  ['permissionConfirmedAt', 'INTEGER NOT NULL DEFAULT 0'],
156
188
  ['runs', "TEXT NOT NULL DEFAULT '[]'"],
189
+ ['checklist', "TEXT NOT NULL DEFAULT '[]'"],
190
+ ['report', "TEXT NOT NULL DEFAULT '{}'"],
191
+ ['comments', "TEXT NOT NULL DEFAULT '[]'"],
192
+ ['rejectReason', "TEXT NOT NULL DEFAULT ''"],
193
+ ['rejectedAt', "INTEGER NOT NULL DEFAULT 0"],
157
194
  ]) {
158
195
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
159
196
  }
@@ -286,8 +323,9 @@ export function openStore({ dir }) {
286
323
  (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
287
324
  labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
288
325
  labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
289
- cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs)
290
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
326
+ cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
327
+ checklist, report, comments, rejectReason, rejectedAt)
328
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
291
329
  id, board, column, position,
292
330
  input.title ?? '', input.body ?? '',
293
331
  input.owner ?? null, input.repo ?? null,
@@ -311,6 +349,11 @@ export function openStore({ dir }) {
311
349
  input.permission ?? '',
312
350
  Number(input.permissionConfirmedAt) || 0,
313
351
  typeof input.runs === 'string' ? input.runs : JSON.stringify(input.runs ?? []),
352
+ typeof input.checklist === 'string' ? input.checklist : JSON.stringify(input.checklist ?? []),
353
+ typeof input.report === 'string' ? input.report : JSON.stringify(input.report ?? {}),
354
+ typeof input.comments === 'string' ? input.comments : JSON.stringify(input.comments ?? []),
355
+ input.rejectReason ?? '',
356
+ Number(input.rejectedAt) || 0,
314
357
  )
315
358
  return rowToTask(q.taskById.get(id))
316
359
  },
@@ -326,6 +369,11 @@ export function openStore({ dir }) {
326
369
  if (key === 'labels') values.push(JSON.stringify(patch[key] ?? []))
327
370
  else if (key === 'labelColors') values.push(JSON.stringify(patch[key] ?? {}))
328
371
  else if (key === 'runs') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
372
+ else if (key === 'checklist') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
373
+ else if (key === 'report') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? {}))
374
+ else if (key === 'comments') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
375
+ else if (key === 'rejectedAt') values.push(Number(patch[key]) || 0)
376
+ else if (key === 'rejectReason') values.push(String(patch[key] ?? ''))
329
377
  else if (key === 'waiting') values.push(patch[key] ? 1 : 0)
330
378
  else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
331
379
  else values.push(patch[key] ?? null)
@@ -362,6 +410,67 @@ export function openStore({ dir }) {
362
410
  return rowToTask(q.taskById.get(id))
363
411
  },
364
412
 
413
+
414
+ addComment(id, { author = 'user', role = 'user', text }) {
415
+ const task = rowToTask(q.taskById.get(id))
416
+ if (!task) return undefined
417
+ const comments = Array.isArray(task.comments) ? [...task.comments] : []
418
+ const item = {
419
+ id: randomUUID(),
420
+ author: String(author || 'user'),
421
+ role: String(role || 'user'),
422
+ text: String(text || '').trim(),
423
+ createdAt: Date.now(),
424
+ }
425
+ comments.push(item)
426
+ return this.updateTask(id, { comments })
427
+ },
428
+
429
+ updateChecklist(id, items) {
430
+ const task = rowToTask(q.taskById.get(id))
431
+ if (!task) return undefined
432
+ const list = Array.isArray(items) ? items : []
433
+ return this.updateTask(id, { checklist: list })
434
+ },
435
+
436
+ setReport(id, reportData) {
437
+ const task = rowToTask(q.taskById.get(id))
438
+ if (!task) return undefined
439
+ const report = (reportData && typeof reportData === 'object') ? reportData : {}
440
+ report.createdAt = report.createdAt || Date.now()
441
+ return this.updateTask(id, { report })
442
+ },
443
+
444
+ rejectTask(id, reason) {
445
+ const task = rowToTask(q.taskById.get(id))
446
+ if (!task) return undefined
447
+ const r = String(reason ?? '').trim()
448
+ const commentText = `[Возврат на доработку / Rejection]: ${r}`
449
+ this.addComment(id, { author: 'user', role: 'user', text: commentText })
450
+ this.moveTask(id, { column: 'in-progress' })
451
+ this.addTransition({
452
+ taskId: id,
453
+ fromCol: task.column,
454
+ toCol: 'in-progress',
455
+ source: 'manual',
456
+ detail: `Rejected: ${r}`,
457
+ })
458
+ return this.updateTask(id, { rejectReason: r, rejectedAt: Date.now() })
459
+ },
460
+
461
+ acceptTask(id) {
462
+ const task = rowToTask(q.taskById.get(id))
463
+ if (!task) return undefined
464
+ this.moveTask(id, { column: 'done' })
465
+ this.addTransition({
466
+ taskId: id,
467
+ fromCol: task.column,
468
+ toCol: 'done',
469
+ source: 'manual',
470
+ detail: 'Accepted by human',
471
+ })
472
+ return this.updateTask(id, { rejectReason: '', rejectedAt: 0 })
473
+ },
365
474
  moveTask(id, { column, beforeId, afterId }) {
366
475
  const current = q.taskById.get(id)
367
476
  if (current === undefined) throw new Error(`задача ${id} не найдена`)
package/lib/worktree.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { execFile } from 'node:child_process'
15
15
  import { promisify } from 'node:util'
16
- import { existsSync } from 'node:fs'
16
+ import { existsSync, readFileSync } from 'node:fs'
17
17
  import { isAbsolute, join, resolve, sep } from 'node:path'
18
18
 
19
19
  const execFileAsync = promisify(execFile)
@@ -202,13 +202,44 @@ export async function createTaskWorktree({
202
202
  /**
203
203
  * Проверить наличие незакоммиченных изменений в воркдереве.
204
204
  */
205
+ function isSubmoduleDrift(worktreePath, line) {
206
+ try {
207
+ const filePath = line.slice(3).trim()
208
+ if (!filePath) return false
209
+ const fullPath = resolve(worktreePath, filePath)
210
+ const gitFile = join(fullPath, '.git')
211
+ if (existsSync(gitFile)) {
212
+ return true
213
+ }
214
+ const gitmodulesPath = join(worktreePath, '.gitmodules')
215
+ if (existsSync(gitmodulesPath)) {
216
+ const content = readFileSync(gitmodulesPath, 'utf8')
217
+ const paths = [...content.matchAll(/path\s*=\s*([^\r\n]+)/g)].map((m) => m[1].trim())
218
+ if (paths.includes(filePath) || paths.some((p) => filePath.startsWith(p + '/') || filePath === p)) {
219
+ return true
220
+ }
221
+ }
222
+ } catch {
223
+ // Игнорируем ошибки доступа к файлам при проверке
224
+ }
225
+ return false
226
+ }
227
+
228
+ /**
229
+ * Проверить наличие незакоммиченных изменений в воркдереве.
230
+ * Исключает структурный шум субмодулей и дрейф gitlinks (#213).
231
+ */
205
232
  export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitRunner }) {
206
233
  if (!worktreePath || !existsSync(worktreePath)) {
207
234
  return { dirty: false, files: [] }
208
235
  }
209
236
  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)
237
+ const res = await gitRunner(['status', '--porcelain', '--ignore-submodules=all'], worktreePath)
238
+ const files = res.stdout
239
+ .split('\n')
240
+ .map((l) => l.trim())
241
+ .filter((l) => l.length > 0)
242
+ .filter((line) => !isSubmoduleDrift(worktreePath, line))
212
243
  return { dirty: files.length > 0, files }
213
244
  } catch (error) {
214
245
  return { dirty: false, files: [], error: String(error?.message ?? error) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
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",