@goodandready/dsh-kanban 0.1.28 → 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.
- package/docs/design/DESIGN.md +9 -2
- package/docs/plans/2026-09-07-quality-acceptance-gateway.md +25 -0
- package/lib/board-tool.js +185 -3
- package/lib/client.js +342 -0
- package/lib/index.js +59 -1
- package/lib/launcher.js +16 -1
- package/lib/routes.js +5 -1
- package/lib/store.js +114 -5
- package/package.json +1 -1
package/docs/design/DESIGN.md
CHANGED
|
@@ -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
|
|
116
|
-
|
|
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' },
|
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 {
|
|
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'
|
|
@@ -617,6 +624,10 @@ export function apply(ctx, config) {
|
|
|
617
624
|
const off = [
|
|
618
625
|
ctx.tools.register(defineTool(boardMoveDefinition({ store }))),
|
|
619
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 }))),
|
|
620
631
|
]
|
|
621
632
|
return () => { for (const stop of off) stop?.() }
|
|
622
633
|
}
|
|
@@ -868,6 +879,53 @@ export function apply(ctx, config) {
|
|
|
868
879
|
files: dirtyCheck.files,
|
|
869
880
|
})
|
|
870
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
|
|
871
929
|
}
|
|
872
930
|
if (req.method === 'POST' && action === 'confirm-permission') {
|
|
873
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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",
|