@goodandready/dsh-kanban 0.1.27 → 0.1.29

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,10 @@
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.
@@ -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`.
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
@@ -1118,6 +1118,33 @@ window.__ModuleLoader__.load({
1118
1118
  'error.giteaUnconfigured': 'Configure Gitea in the Gitea plugin card first.',
1119
1119
  'error.taskNotFound': 'Task not found.',
1120
1120
  'error.taskHasNoIssue': 'This task has no Gitea issue behind it.',
1121
+ 'checklist.title': 'Definition of Done (DoD)',
1122
+ 'checklist.empty': 'No DoD items configured yet.',
1123
+ 'checklist.add': 'Add',
1124
+ 'checklist.placeholder': 'New DoD criterion...',
1125
+ 'checklist.progress': 'DoD: {done}/{total}',
1126
+ 'checklist.required': 'Required',
1127
+ 'checklist.evidence': 'Evidence: {text}',
1128
+ 'report.title': 'Execution Report',
1129
+ 'report.summary': 'Summary',
1130
+ 'report.changedFiles': 'Changed files',
1131
+ 'report.checksRun': 'Verification checks',
1132
+ 'report.artifacts': 'Artifacts',
1133
+ 'report.risks': 'Risks & notes',
1134
+ 'acceptance.title': 'Review Acceptance Gate',
1135
+ 'acceptance.accept': 'Accept (Done)',
1136
+ 'acceptance.reject': 'Send back for rework',
1137
+ 'acceptance.rejectTitle': 'Return to work',
1138
+ 'acceptance.rejectReasonPlaceholder': 'State what needs to be fixed or improved...',
1139
+ 'acceptance.rejectSubmit': 'Send back',
1140
+ 'acceptance.rejectedBadge': 'Rework',
1141
+ 'acceptance.rejectedAlert': 'Returned for rework: {reason}',
1142
+ 'comments.title': 'Discussion & Comments',
1143
+ 'comments.empty': 'No comments yet.',
1144
+ 'comments.add': 'Post comment',
1145
+ 'comments.placeholder': 'Add a note or feedback...',
1146
+ 'comments.authorUser': 'User',
1147
+ 'comments.authorAgent': 'Agent',
1121
1148
  'error.issueNotFound': 'Issue not found.',
1122
1149
  'error.titleRequired': 'A title is required.',
1123
1150
  'error.crossSite': 'Request rejected as cross-site.',
@@ -1412,6 +1439,33 @@ window.__ModuleLoader__.load({
1412
1439
  'error.giteaUnconfigured': '请先在 Gitea 插件的设置卡片中完成配置。',
1413
1440
  'error.taskNotFound': '没有找到任务。',
1414
1441
  'error.taskHasNoIssue': '该任务背后没有 Gitea issue。',
1442
+ 'checklist.title': '完成定义 (DoD)',
1443
+ 'checklist.empty': '尚未配置 DoD 检查项。',
1444
+ 'checklist.add': '添加',
1445
+ 'checklist.placeholder': '新的 DoD 验收标准...',
1446
+ 'checklist.progress': 'DoD: {done}/{total}',
1447
+ 'checklist.required': '必填',
1448
+ 'checklist.evidence': '证明: {text}',
1449
+ 'report.title': '执行报告',
1450
+ 'report.summary': '概述',
1451
+ 'report.changedFiles': '变更文件',
1452
+ 'report.checksRun': '验证检查',
1453
+ 'report.artifacts': '生成产物',
1454
+ 'report.risks': '风险与说明',
1455
+ 'acceptance.title': '评审验收网关',
1456
+ 'acceptance.accept': '验收通过 (完成)',
1457
+ 'acceptance.reject': '退回重做',
1458
+ 'acceptance.rejectTitle': '退回修改',
1459
+ 'acceptance.rejectReasonPlaceholder': '说明需要修改或补充的问题...',
1460
+ 'acceptance.rejectSubmit': '确认退回',
1461
+ 'acceptance.rejectedBadge': '需重做',
1462
+ 'acceptance.rejectedAlert': '退回重做说明: {reason}',
1463
+ 'comments.title': '讨论与评论',
1464
+ 'comments.empty': '暂无评论。',
1465
+ 'comments.add': '发表评论',
1466
+ 'comments.placeholder': '添加说明或反馈...',
1467
+ 'comments.authorUser': '用户',
1468
+ 'comments.authorAgent': 'Agent',
1415
1469
  'error.issueNotFound': '没有找到 issue。',
1416
1470
  'error.titleRequired': '标题不能为空。',
1417
1471
  'error.crossSite': '请求被判定为跨站而拒绝。',
@@ -1680,6 +1734,16 @@ window.__ModuleLoader__.load({
1680
1734
  taskRef(task, t),
1681
1735
  task.cron ? React.createElement('span', { className: 'dkb-tag', title: task.cron }, '⏰ cron') : null,
1682
1736
  task.progressDump ? React.createElement('span', { className: 'dkb-tag', title: 'PROGRESSDUMP' }, '📋 dump') : null,
1737
+ 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,
1738
+ Array.isArray(task.checklist) && task.checklist.length > 0
1739
+ ? React.createElement('span', { className: 'dkb-tag', title: 'DoD Checklist' }, `☑ ${task.checklist.filter((i) => i.completed).length}/${task.checklist.length}`)
1740
+ : null,
1741
+ task.report && task.report.summary
1742
+ ? React.createElement('span', { className: 'dkb-tag', title: 'Execution Report' }, '📄 report')
1743
+ : null,
1744
+ Array.isArray(task.comments) && task.comments.length > 0
1745
+ ? React.createElement('span', { className: 'dkb-tag', title: 'Comments' }, `💬 ${task.comments.length}`)
1746
+ : null,
1683
1747
  (task.labels || []).map((l) => React.createElement('span', {
1684
1748
  className: 'dkb-tag', key: l, style: tagStyle(task, l),
1685
1749
  }, l))),
@@ -1824,6 +1888,10 @@ window.__ModuleLoader__.load({
1824
1888
  const [state, setState] = React.useState(null)
1825
1889
  const [error, setError] = React.useState('')
1826
1890
  const [openTask, setOpenTask] = React.useState(null)
1891
+ const [rejectModalOpen, setRejectModalOpen] = React.useState(false)
1892
+ const [rejectReasonText, setRejectReasonText] = React.useState('')
1893
+ const [newChecklistText, setNewChecklistText] = React.useState('')
1894
+ const [newCommentText, setNewCommentText] = React.useState('')
1827
1895
  const [log, setLog] = React.useState([])
1828
1896
  const [dialog, setDialog] = React.useState(null)
1829
1897
  // Черновик правки приоритета и меток в окне задачи. Живёт отдельно от
@@ -2760,6 +2828,280 @@ window.__ModuleLoader__.load({
2760
2828
  ))),
2761
2829
  )
2762
2830
  : null,
2831
+ openTask.rejectReason
2832
+ ? React.createElement('div', {
2833
+ className: 'dkb-warnBox',
2834
+ style: {
2835
+ margin: '4px 0', padding: 10,
2836
+ background: 'var(--dsw-alias-state-danger-bg, #ffebe9)',
2837
+ border: '1px solid var(--dsw-alias-state-danger-border, #ffc1c0)',
2838
+ borderRadius: 6,
2839
+ },
2840
+ },
2841
+ React.createElement('div', {
2842
+ style: { marginBottom: 4, fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-state-danger-primary, #cf222e)' },
2843
+ }, '⚠️ ' + t('acceptance.rejectedAlert', { reason: openTask.rejectReason })),
2844
+ )
2845
+ : null,
2846
+ openTask.column === 'review'
2847
+ ? React.createElement('div', {
2848
+ style: {
2849
+ margin: '8px 0', padding: 12,
2850
+ background: 'var(--dsw-alias-bg-layer-3)',
2851
+ border: '1px solid var(--dsw-alias-border-l2)',
2852
+ borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 8,
2853
+ },
2854
+ },
2855
+ React.createElement('div', { className: 'dkb-group', style: { margin: 0 } }, t('acceptance.title')),
2856
+ React.createElement('div', { style: { display: 'flex', gap: 8 } },
2857
+ React.createElement('button', {
2858
+ type: 'button', className: 'dkb-save',
2859
+ onClick: async () => {
2860
+ try {
2861
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/accept', { method: 'POST', body: '{}' })
2862
+ if (out && out.task) setOpenTask(out.task)
2863
+ await load(board)
2864
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2865
+ },
2866
+ }, '✓ ' + t('acceptance.accept')),
2867
+ React.createElement('button', {
2868
+ type: 'button', className: 'dkb-discard',
2869
+ onClick: () => { setRejectModalOpen(true); setRejectReasonText('') },
2870
+ }, '✗ ' + t('acceptance.reject')),
2871
+ ),
2872
+ rejectModalOpen
2873
+ ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 } },
2874
+ React.createElement('span', { className: 'dkb-label' }, t('acceptance.rejectTitle')),
2875
+ React.createElement('textarea', {
2876
+ className: 'dkb-area',
2877
+ value: rejectReasonText,
2878
+ placeholder: t('acceptance.rejectReasonPlaceholder'),
2879
+ onChange: (e) => setRejectReasonText(e.target.value),
2880
+ rows: 3,
2881
+ }),
2882
+ React.createElement('div', { style: { display: 'flex', gap: 6 } },
2883
+ React.createElement('button', {
2884
+ type: 'button', className: 'dkb-save',
2885
+ onClick: async () => {
2886
+ if (!rejectReasonText.trim()) return
2887
+ try {
2888
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/reject', {
2889
+ method: 'POST',
2890
+ body: JSON.stringify({ reason: rejectReasonText.trim() }),
2891
+ })
2892
+ if (out && out.task) setOpenTask(out.task)
2893
+ setRejectModalOpen(false)
2894
+ await load(board)
2895
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2896
+ },
2897
+ }, t('acceptance.rejectSubmit')),
2898
+ React.createElement('button', {
2899
+ type: 'button', className: 'dkb-discard',
2900
+ onClick: () => setRejectModalOpen(false),
2901
+ }, t('dialog.cancel')),
2902
+ ),
2903
+ )
2904
+ : null,
2905
+ )
2906
+ : null,
2907
+ React.createElement('div', { style: { margin: '4px 0' } },
2908
+ React.createElement('div', { className: 'dkb-group', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
2909
+ React.createElement('span', null, t('checklist.title')),
2910
+ Array.isArray(openTask.checklist) && openTask.checklist.length > 0
2911
+ ? React.createElement('span', { className: 'dkb-hint', style: { fontWeight: 'normal' } },
2912
+ t('checklist.progress', {
2913
+ done: openTask.checklist.filter((i) => i.completed).length,
2914
+ total: openTask.checklist.length,
2915
+ }))
2916
+ : null,
2917
+ ),
2918
+ Array.isArray(openTask.checklist) && openTask.checklist.length > 0
2919
+ ? React.createElement('ul', { style: { listStyle: 'none', padding: 0, margin: '4px 0', display: 'flex', flexDirection: 'column', gap: 4 } },
2920
+ openTask.checklist.map((item, idx) => React.createElement('li', {
2921
+ key: idx,
2922
+ style: {
2923
+ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 8px',
2924
+ background: item.completed ? 'var(--dsw-alias-bg-layer-1)' : 'var(--dsw-alias-bg-layer-3)',
2925
+ borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)',
2926
+ },
2927
+ },
2928
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
2929
+ React.createElement('input', {
2930
+ type: 'checkbox',
2931
+ checked: Boolean(item.completed),
2932
+ onChange: async (e) => {
2933
+ const updated = [...openTask.checklist]
2934
+ updated[idx] = { ...updated[idx], completed: e.target.checked }
2935
+ try {
2936
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/checklist', {
2937
+ method: 'POST',
2938
+ body: JSON.stringify({ checklist: updated }),
2939
+ })
2940
+ if (out && out.task) setOpenTask(out.task)
2941
+ await load(board)
2942
+ } catch (err) { setError(t(err.key || 'error.unknown')) }
2943
+ },
2944
+ }),
2945
+ React.createElement('span', {
2946
+ style: {
2947
+ textDecoration: item.completed ? 'line-through' : 'none',
2948
+ color: item.completed ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-label-primary)',
2949
+ fontSize: 13, flex: 1,
2950
+ },
2951
+ }, item.text),
2952
+ item.required !== false
2953
+ ? React.createElement('span', { className: 'dkb-tag', style: { fontSize: 10 } }, t('checklist.required'))
2954
+ : null,
2955
+ ),
2956
+ item.evidence
2957
+ ? React.createElement('div', {
2958
+ style: {
2959
+ fontSize: 11, color: 'var(--dsw-alias-label-secondary)', marginLeft: 24,
2960
+ fontFamily: 'var(--dsw-font-family-mono, monospace)', whiteSpace: 'pre-wrap',
2961
+ },
2962
+ }, t('checklist.evidence', { text: item.evidence }))
2963
+ : null,
2964
+ )))
2965
+ : React.createElement('p', { className: 'dkb-hint' }, t('checklist.empty')),
2966
+ React.createElement('div', { style: { display: 'flex', gap: 6, marginTop: 4 } },
2967
+ React.createElement('input', {
2968
+ className: 'dkb-input',
2969
+ style: { flex: 1 },
2970
+ placeholder: t('checklist.placeholder'),
2971
+ value: newChecklistText,
2972
+ onChange: (e) => setNewChecklistText(e.target.value),
2973
+ onKeyDown: async (e) => {
2974
+ if (e.key === 'Enter' && newChecklistText.trim()) {
2975
+ e.preventDefault()
2976
+ const updated = [...(openTask.checklist || []), { text: newChecklistText.trim(), required: true, completed: false }]
2977
+ setNewChecklistText('')
2978
+ try {
2979
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/checklist', {
2980
+ method: 'POST',
2981
+ body: JSON.stringify({ checklist: updated }),
2982
+ })
2983
+ if (out && out.task) setOpenTask(out.task)
2984
+ await load(board)
2985
+ } catch (err) { setError(t(err.key || 'error.unknown')) }
2986
+ }
2987
+ },
2988
+ }),
2989
+ React.createElement('button', {
2990
+ type: 'button', className: 'dkb-save',
2991
+ onClick: async () => {
2992
+ if (!newChecklistText.trim()) return
2993
+ const updated = [...(openTask.checklist || []), { text: newChecklistText.trim(), required: true, completed: false }]
2994
+ setNewChecklistText('')
2995
+ try {
2996
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/checklist', {
2997
+ method: 'POST',
2998
+ body: JSON.stringify({ checklist: updated }),
2999
+ })
3000
+ if (out && out.task) setOpenTask(out.task)
3001
+ await load(board)
3002
+ } catch (err) { setError(t(err.key || 'error.unknown')) }
3003
+ },
3004
+ }, t('checklist.add')),
3005
+ ),
3006
+ ),
3007
+ openTask.report && openTask.report.summary
3008
+ ? React.createElement('div', {
3009
+ style: {
3010
+ margin: '6px 0', padding: 10,
3011
+ background: 'var(--dsw-alias-bg-layer-1)',
3012
+ border: '1px solid var(--dsw-alias-border-l2)',
3013
+ borderRadius: 6,
3014
+ },
3015
+ },
3016
+ React.createElement('div', { className: 'dkb-group', style: { margin: '0 0 6px 0' } }, t('report.title')),
3017
+ React.createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-primary)', marginBottom: 6, fontWeight: 500 } },
3018
+ openTask.report.summary),
3019
+ openTask.report.changedFiles && openTask.report.changedFiles.length
3020
+ ? React.createElement('div', { style: { fontSize: 12, marginBottom: 4 } },
3021
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('report.changedFiles') + ': '),
3022
+ openTask.report.changedFiles.map((f) => React.createElement('span', { className: 'dkb-tag', key: f, style: { margin: '1px 2px', fontFamily: 'monospace' } }, f)),
3023
+ )
3024
+ : null,
3025
+ openTask.report.checksRun && openTask.report.checksRun.length
3026
+ ? React.createElement('div', { style: { fontSize: 12, marginBottom: 4 } },
3027
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('report.checksRun') + ': '),
3028
+ openTask.report.checksRun.map((chk, i) => React.createElement('div', { key: i, style: { fontFamily: 'monospace', fontSize: 11, marginLeft: 8 } }, '• ' + chk)),
3029
+ )
3030
+ : null,
3031
+ openTask.report.artifacts && openTask.report.artifacts.length
3032
+ ? React.createElement('div', { style: { fontSize: 12, marginBottom: 4 } },
3033
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('report.artifacts') + ': '),
3034
+ openTask.report.artifacts.map((a, i) => React.createElement('span', { className: 'dkb-tag', key: i, style: { margin: '1px 2px' } }, a)),
3035
+ )
3036
+ : null,
3037
+ openTask.report.risks && openTask.report.risks.length
3038
+ ? React.createElement('div', { style: { fontSize: 12 } },
3039
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('report.risks') + ': '),
3040
+ 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)),
3041
+ )
3042
+ : null,
3043
+ )
3044
+ : null,
3045
+ React.createElement('div', { style: { margin: '6px 0' } },
3046
+ React.createElement('div', { className: 'dkb-group', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
3047
+ React.createElement('span', null, t('comments.title')),
3048
+ Array.isArray(openTask.comments) && openTask.comments.length > 0
3049
+ ? React.createElement('span', { className: 'dkb-hint' }, `(${openTask.comments.length})`)
3050
+ : null,
3051
+ ),
3052
+ Array.isArray(openTask.comments) && openTask.comments.length > 0
3053
+ ? React.createElement('ul', {
3054
+ style: {
3055
+ listStyle: 'none', padding: 0, margin: '4px 0', maxHeight: 200, overflowY: 'auto',
3056
+ display: 'flex', flexDirection: 'column', gap: 6,
3057
+ },
3058
+ },
3059
+ openTask.comments.map((c, i) => React.createElement('li', {
3060
+ key: c.id || i,
3061
+ style: {
3062
+ padding: 8, background: 'var(--dsw-alias-bg-layer-3)',
3063
+ borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', fontSize: 12,
3064
+ },
3065
+ },
3066
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', marginBottom: 4 } },
3067
+ React.createElement('span', {
3068
+ className: 'dkb-tag',
3069
+ style: {
3070
+ fontWeight: 600,
3071
+ background: c.role === 'agent' ? 'var(--dsw-alias-bg-layer-1)' : 'var(--dsw-alias-bg-layer-2)',
3072
+ },
3073
+ }, c.role === 'agent' ? t('comments.authorAgent') : (c.author || t('comments.authorUser'))),
3074
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 11 } },
3075
+ new Date(c.createdAt || Date.now()).toLocaleTimeString()),
3076
+ ),
3077
+ React.createElement('div', { style: { whiteSpace: 'pre-wrap', color: 'var(--dsw-alias-label-primary)' } }, c.text),
3078
+ )))
3079
+ : React.createElement('p', { className: 'dkb-hint' }, t('comments.empty')),
3080
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, marginTop: 4 } },
3081
+ React.createElement('textarea', {
3082
+ className: 'dkb-area',
3083
+ placeholder: t('comments.placeholder'),
3084
+ value: newCommentText,
3085
+ rows: 2,
3086
+ onChange: (e) => setNewCommentText(e.target.value),
3087
+ }),
3088
+ React.createElement('button', {
3089
+ type: 'button', className: 'dkb-save', style: { alignSelf: 'flex-start' },
3090
+ onClick: async () => {
3091
+ if (!newCommentText.trim()) return
3092
+ try {
3093
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/comments', {
3094
+ method: 'POST',
3095
+ body: JSON.stringify({ text: newCommentText.trim(), role: 'user', author: 'user' }),
3096
+ })
3097
+ if (out && out.task) setOpenTask(out.task)
3098
+ setNewCommentText('')
3099
+ await load(board)
3100
+ } catch (err) { setError(t(err.key || 'error.unknown')) }
3101
+ },
3102
+ }, t('comments.add')),
3103
+ ),
3104
+ ),
2763
3105
  openTask.archivedAt
2764
3106
  ? React.createElement('button', {
2765
3107
  type: 'button', className: 'dkb-save', style: { alignSelf: 'flex-start' },
@@ -3651,6 +3993,9 @@ window.__ModuleLoader__.load({
3651
3993
  function registerFirst(ctx, candidates, component) {
3652
3994
  for (const entry of candidates) {
3653
3995
  try {
3996
+ if (typeof ctx.slots?.has === 'function' && !ctx.slots.has(entry.name)) {
3997
+ continue
3998
+ }
3654
3999
  let ok = false
3655
4000
  ctx.slots.inject(entry.name, () => {
3656
4001
  ctx.slots.register(entry, component)
package/lib/gitea.js CHANGED
@@ -79,10 +79,10 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
79
79
  async function request(method, path, body) {
80
80
  const config = getConfig()
81
81
  const baseUrl = normalizeBaseUrl(config?.giteaUrl)
82
- if (baseUrl === '') throw Object.assign(new Error('адрес Gitea не задан'), { code: 'unconfigured' })
82
+ if (baseUrl === '') throw Object.assign(new Error('Gitea URL is not configured'), { code: 'unconfigured' })
83
83
 
84
84
  const token = await resolveToken(config?.giteaTokenRef)
85
- if (!token) throw Object.assign(new Error('учётная запись с токеном не настроена'), { code: 'unconfigured' })
85
+ if (!token) throw Object.assign(new Error('Gitea credential token is not configured'), { code: 'unconfigured' })
86
86
 
87
87
  const controller = new AbortController()
88
88
  const timer = setTimeout(() => controller.abort(), timeoutMs)
@@ -104,7 +104,7 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
104
104
  try { payload = text.trim() === '' ? null : JSON.parse(text) } catch { payload = null }
105
105
  if (!res.ok) {
106
106
  // Тело ответа наружу не выносим: в нём может быть эхо заголовков.
107
- throw Object.assign(new Error(`Gitea ответил ${res.status}`), { code: 'http', status: res.status })
107
+ throw Object.assign(new Error(`Gitea HTTP error ${res.status}`), { code: 'http', status: res.status })
108
108
  }
109
109
  return payload
110
110
  } finally {
@@ -116,7 +116,7 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
116
116
  const o = safeSegment(owner)
117
117
  const r = safeSegment(repo)
118
118
  if (o === undefined || r === undefined) {
119
- throw Object.assign(new Error('недопустимый владелец или репозиторий'), { code: 'bad-segment' })
119
+ throw Object.assign(new Error('Invalid owner or repository segment'), { code: 'bad-segment' })
120
120
  }
121
121
  return `/repos/${o}/${r}${suffix}`
122
122
  }
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'
@@ -76,9 +83,12 @@ export const Config = z.object(Object.fromEntries(
76
83
  Object.entries(CONFIG_DEFAULTS).map(([field, fallback]) => {
77
84
  // Узел схемы выбирается по типу умолчания. Пропустить здесь булево значит
78
85
  // объявить его строкой — и плагин не загрузится вовсе с «invalid config».
79
- const node = typeof fallback === 'number' ? z.number()
86
+ let node = typeof fallback === 'number' ? z.number()
80
87
  : typeof fallback === 'boolean' ? z.boolean()
81
88
  : z.string()
89
+ if (field === 'giteaTokenRef' || field === 'webhookSecretRef') {
90
+ node = node.role('credential-ref')
91
+ }
82
92
  return [field, node.default(fallback).description(CONFIG_HINTS[field] ?? '')]
83
93
  }),
84
94
  ))
@@ -614,6 +624,10 @@ export function apply(ctx, config) {
614
624
  const off = [
615
625
  ctx.tools.register(defineTool(boardMoveDefinition({ store }))),
616
626
  ctx.tools.register(defineTool(boardPlanDefinition({ store }))),
627
+ ctx.tools.register(defineTool(boardChecklistDefinition({ store }))),
628
+ ctx.tools.register(defineTool(boardReportDefinition({ store }))),
629
+ ctx.tools.register(defineTool(boardCommentsDefinition({ store }))),
630
+ ctx.tools.register(defineTool(boardCommentAddDefinition({ store }))),
617
631
  ]
618
632
  return () => { for (const stop of off) stop?.() }
619
633
  }
@@ -865,6 +879,53 @@ export function apply(ctx, config) {
865
879
  files: dirtyCheck.files,
866
880
  })
867
881
  return
882
+ }
883
+ if (req.method === 'POST' && action === 'checklist') {
884
+ const task = store.getTask(id)
885
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
886
+ const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
887
+ const updated = store.updateChecklist(id, list)
888
+ reply(res, { task: updated })
889
+ return
890
+ }
891
+ if (req.method === 'POST' && action === 'report') {
892
+ const task = store.getTask(id)
893
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
894
+ const updated = store.setReport(id, input)
895
+ reply(res, { task: updated })
896
+ return
897
+ }
898
+ if (req.method === 'GET' && action === 'comments') {
899
+ const task = store.getTask(id)
900
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
901
+ reply(res, { comments: task.comments || [] })
902
+ return
903
+ }
904
+ if (req.method === 'POST' && action === 'comments') {
905
+ const task = store.getTask(id)
906
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
907
+ const author = input?.author || 'user'
908
+ const role = input?.role || 'user'
909
+ const text = input?.text || ''
910
+ const updated = store.addComment(id, { author, role, text })
911
+ reply(res, { task: updated })
912
+ return
913
+ }
914
+ if (req.method === 'POST' && action === 'accept') {
915
+ const task = store.getTask(id)
916
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
917
+ const updated = store.acceptTask(id)
918
+ reply(res, { task: updated })
919
+ return
920
+ }
921
+ if (req.method === 'POST' && action === 'reject') {
922
+ const task = store.getTask(id)
923
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
924
+ const reason = String(input?.reason || '').trim()
925
+ if (!reason) { writeJson(res, 400, { error: 'reason-required' }); return }
926
+ const updated = store.rejectTask(id, reason)
927
+ reply(res, { task: updated })
928
+ return
868
929
  }
869
930
  if (req.method === 'POST' && action === 'confirm-permission') {
870
931
  reply(res, confirmPermission({ store, id }))
package/lib/launcher.js CHANGED
@@ -388,7 +388,22 @@ export async function runTask({
388
388
  /** Текст сообщения: человеческий, если он есть, иначе встроенная заготовка. */
389
389
  function messageFor(task, config, text) {
390
390
  const own = typeof text === 'string' ? text.trim() : ''
391
- const base = own !== '' ? own : buildStartMessage(task, config)
391
+ let base = own !== '' ? own : buildStartMessage(task, config)
392
+ let qualityNotes = ''
393
+ if (task?.rejectReason) {
394
+ qualityNotes += `### ⚠️ Внимание: Задача возвращена на доработку\nЗамечания к приёмке (причина возврата):\n${task.rejectReason}\n\n`
395
+ }
396
+ if (Array.isArray(task?.checklist) && task.checklist.length > 0) {
397
+ const uncompleted = task.checklist.filter((i) => i.required !== false && !i.completed)
398
+ if (uncompleted.length > 0) {
399
+ qualityNotes += `### Обязательные критерии готовности (DoD):\n`
400
+ + uncompleted.map((item, idx) => `${idx + 1}. [ ] ${item.text}`).join('\n')
401
+ + `\nДля фиксации выполнения используй инструмент board_checklist(item, evidence).\n\n`
402
+ }
403
+ }
404
+ if (qualityNotes) {
405
+ base = qualityNotes + base
406
+ }
392
407
  if (config?.progressDumpEnabled && task.progressDump) {
393
408
  let parsed
394
409
  try {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
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",