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