@goodandready/dsh-kanban 0.1.34 → 0.2.0
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 +14 -0
- package/lib/client.js +100 -1
- package/lib/config.js +2 -0
- package/lib/events.js +91 -0
- package/lib/index.js +79 -1
- package/lib/routes.js +68 -0
- package/lib/store.js +7 -3
- package/lib/templates.js +65 -0
- package/lib/worktree.js +97 -1
- package/package.json +1 -1
package/docs/design/DESIGN.md
CHANGED
|
@@ -67,6 +67,20 @@
|
|
|
67
67
|
- Фильтрация сессий субагентов: сессии с маркером subagent (origin === 'subagent' или parentId !== undefined) исключаются из liveSessions и не перезаписывают состояние корневых задач (#215).
|
|
68
68
|
## Locked Decisions
|
|
69
69
|
|
|
70
|
+
### 2026-09-08: Релиз v0.2.0 — Milestone (SSE, Chat Sync, Templates, Two-Column Drawer, Multi-Repo) (#210, #219, #220, #222, #225)
|
|
71
|
+
- **Live SSE Channel (`/dsh-kanban/events`)**: Постоянное SSE-соединение с автоматическим переподключением для мгновенного пуша обновлений доски (`task:change`, `board:change`) без избыточного поллинга (#222).
|
|
72
|
+
- **Auto-sync Sessions (`autoSyncSessions`)**: Опциональная автоматическая синхронизация стандартных диалогов чата DSH в карточки доски канбана с надежной фильтрацией внутренних сессий и субагентов (#225).
|
|
73
|
+
- **Task Templates Library (`/dsh-kanban/templates`)**: Библиотека из 5 встроенных типовых шаблонов (Feature, Bugfix, Refactor, Security, Release) с предзаполненными чеклистами DoD и шаблонами промптов (#220).
|
|
74
|
+
- **Two-Column Drawer Layout**: Адаптивный двухколоночный макет для TaskDrawer на экранах шириной от 960px с разделением метаданных и контентной области (#219).
|
|
75
|
+
- **Multi-Repo Workspace Mirroring**: Сканирование нескольких вложенных git-репозиториев (`findWorkspaceGitRepos`) и подготовка зеркальных воркдеревьев на единой ветке задачи (`prepareMultiRepoMirror`) (#210).
|
|
76
|
+
|
|
77
|
+
### 2026-09-08: Релиз Vector 4 (UX оператора, управление моделями и аналитика) (#217, #218, #221, #223, #228)
|
|
78
|
+
- **reasoningEffort**: Добавлен селектор глубины рассуждения (low, medium, high) в карточке `TaskDrawer`, сохраняется в SQLite и передается в метаданные сессии DSH для моделей с рассуждением (DeepSeek-R1, OpenAI o1/o3).
|
|
79
|
+
- **Экспорт в CSV с UTF-8 BOM**: Добавлен эндпоинт `GET /dsh-kanban/export.csv` и кнопка в тулбаре для выгрузки аналитики задач в Microsoft Excel без искажения кодировки.
|
|
80
|
+
- **Адаптивный 36x36 Rail Icon**: Кнопка канбана в сайдбаре автоматически принимает компактный размер 36×36 px с центрированием иконки при сворачивании панели в rail.
|
|
81
|
+
- **Lightbox для изображений**: Превью Markdown-изображений в карточке открываются в модальном полноэкранном лайтбоксе.
|
|
82
|
+
|
|
83
|
+
|
|
70
84
|
### 2026-09-08: Релиз Vector 3 (Просмотр Git Diff / Коммитов и Однокликовый Merge) (#211, #212)
|
|
71
85
|
- **Git Diff & Commits Viewer**: В `TaskDrawer` интегрирован блок просмотра истории коммитов сессии и unified diff измененных файлов воркдерева (`GET /tasks/:id/diff` и `GET /tasks/:id/commits`). Diff безопасно ограничен 250 КБ.
|
|
72
86
|
- **One-Click Merge (--no-ff)**: В секции Review Acceptance Gate добавлена кнопка «✓ Принять и смержить» (`POST /tasks/:id/merge`). Выполняется проверка на отсутствие незакоммиченных изменений (`checkWorktreeDirty`), слияние `git merge --no-ff` в целевую ветку (`main`), автоматическая дерегистрация и удаление воркдерева с переводом задачи в `done`.
|
package/lib/client.js
CHANGED
|
@@ -806,6 +806,9 @@ window.__ModuleLoader__.load({
|
|
|
806
806
|
// оказывается под ними — нажать нельзя, а выглядит как «ничего не работает».
|
|
807
807
|
'.dkb-modal{position:absolute;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;padding:24px 24px 84px;box-sizing:border-box;z-index:70}' +
|
|
808
808
|
'.dkb-panel{width:min(720px,100%);max-height:100%;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:16px;box-sizing:border-box;overflow-y:auto;display:flex;flex-direction:column;gap:12px}' +
|
|
809
|
+
'@media (min-width: 960px){.dkb-panelWide{width:min(1100px,96%)}.dkb-drawerGrid{display:grid;grid-template-columns:300px 1fr;gap:20px;align-items:start}}' +
|
|
810
|
+
'.dkb-drawerSidebar{display:flex;flex-direction:column;gap:12px;border-right:1px solid var(--dsw-alias-border-l2);padding-right:16px}' +
|
|
811
|
+
'.dkb-drawerMain{display:flex;flex-direction:column;gap:12px;min-width:0}' +
|
|
809
812
|
'.dkb-panelBody{white-space:pre-wrap;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.5;margin:0}' +
|
|
810
813
|
'.dkb-log{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}' +
|
|
811
814
|
'.dkb-logRow{color:var(--dsw-alias-label-secondary);font-size:12px}' +
|
|
@@ -875,6 +878,8 @@ window.__ModuleLoader__.load({
|
|
|
875
878
|
'field.announceToAgent': 'Announce board in system prompt',
|
|
876
879
|
'field.maxConcurrentSessions': 'Max concurrent agent sessions',
|
|
877
880
|
'field.workspaceClaimBoundaries': 'Workspace claim boundaries',
|
|
881
|
+
'field.autoSyncSessions': 'Auto-sync external chat sessions',
|
|
882
|
+
'hint.autoSyncSessions': 'Automatically create board cards for standard DSH chat conversations.',
|
|
878
883
|
'hint.announceToAgent': 'Injects guidance section into the agent system prompt.',
|
|
879
884
|
'task.runs': 'Execution History',
|
|
880
885
|
'task.noRuns': 'No runs yet',
|
|
@@ -1134,6 +1139,20 @@ window.__ModuleLoader__.load({
|
|
|
1134
1139
|
'report.checksRun': 'Verification checks',
|
|
1135
1140
|
'report.artifacts': 'Artifacts',
|
|
1136
1141
|
'report.risks': 'Risks & notes',
|
|
1142
|
+
'reasoning.label': 'Reasoning Effort',
|
|
1143
|
+
'reasoning.default': 'Default (Model)',
|
|
1144
|
+
'reasoning.low': 'Low (Fast / Economical)',
|
|
1145
|
+
'reasoning.medium': 'Medium (Balanced)',
|
|
1146
|
+
'reasoning.high': 'High (Deep Thinking)',
|
|
1147
|
+
'action.exportCsv': 'Export CSV (Excel)',
|
|
1148
|
+
'slash.hint': 'Type / for commands & skills',
|
|
1149
|
+
'template.label': 'Task Template',
|
|
1150
|
+
'template.none': 'None (Empty template)',
|
|
1151
|
+
'template.feature': 'Feature (New functionality)',
|
|
1152
|
+
'template.bugfix': 'Bugfix (Defect fix)',
|
|
1153
|
+
'template.refactor': 'Refactor (Code cleanup)',
|
|
1154
|
+
'template.security': 'Security (Audit & CVE)',
|
|
1155
|
+
'template.release': 'Release (Release preparation)',
|
|
1137
1156
|
'diff.title': 'Git Changes & Diff',
|
|
1138
1157
|
'diff.empty': 'No changes detected in worktree.',
|
|
1139
1158
|
'diff.commits': 'Commits ({n})',
|
|
@@ -1208,6 +1227,8 @@ window.__ModuleLoader__.load({
|
|
|
1208
1227
|
'field.announceToAgent': '在系统提示词中声明看板',
|
|
1209
1228
|
'field.maxConcurrentSessions': '最大并发会话数',
|
|
1210
1229
|
'field.workspaceClaimBoundaries': '工作区认领边界',
|
|
1230
|
+
'field.autoSyncSessions': '自动同步外部聊天会话',
|
|
1231
|
+
'hint.autoSyncSessions': '自动将 DSH 的标准聊天会话同步为看板任务卡片。',
|
|
1211
1232
|
'hint.announceToAgent': '向 Agent 系统提示词注入看板与进度协作说明。',
|
|
1212
1233
|
'task.runs': '执行历史',
|
|
1213
1234
|
'task.noRuns': '暂无执行记录',
|
|
@@ -1467,6 +1488,20 @@ window.__ModuleLoader__.load({
|
|
|
1467
1488
|
'report.checksRun': '验证检查',
|
|
1468
1489
|
'report.artifacts': '生成产物',
|
|
1469
1490
|
'report.risks': '风险与说明',
|
|
1491
|
+
'reasoning.label': '思考推理深度',
|
|
1492
|
+
'reasoning.default': '默认(模型配置)',
|
|
1493
|
+
'reasoning.low': '低(快速/省Token)',
|
|
1494
|
+
'reasoning.medium': '中(均衡)',
|
|
1495
|
+
'reasoning.high': '高(深度推理)',
|
|
1496
|
+
'action.exportCsv': '导出 CSV (Excel)',
|
|
1497
|
+
'slash.hint': '输入 / 获取命令与技能',
|
|
1498
|
+
'template.label': '任务模板',
|
|
1499
|
+
'template.none': '无(空白模板)',
|
|
1500
|
+
'template.feature': '功能特性 (Feature)',
|
|
1501
|
+
'template.bugfix': '缺陷修复 (Bugfix)',
|
|
1502
|
+
'template.refactor': '重构优化 (Refactor)',
|
|
1503
|
+
'template.security': '安全审计 (Security)',
|
|
1504
|
+
'template.release': '版本发布 (Release)',
|
|
1470
1505
|
'diff.title': 'Git 变更与 Diff',
|
|
1471
1506
|
'diff.empty': '工作区未检测到变更。',
|
|
1472
1507
|
'diff.commits': '提交历史 ({n})',
|
|
@@ -1537,6 +1572,7 @@ window.__ModuleLoader__.load({
|
|
|
1537
1572
|
{ field: 'announceToAgent', kind: 'bool', group: 'general' },
|
|
1538
1573
|
{ field: 'maxConcurrentSessions', kind: 'number', group: 'limits' },
|
|
1539
1574
|
{ field: 'workspaceClaimBoundaries', kind: 'bool', group: 'general' },
|
|
1575
|
+
{ field: 'autoSyncSessions', kind: 'bool', group: 'sync' },
|
|
1540
1576
|
]
|
|
1541
1577
|
|
|
1542
1578
|
function hintKey(spec) {
|
|
@@ -1990,6 +2026,47 @@ window.__ModuleLoader__.load({
|
|
|
1990
2026
|
|
|
1991
2027
|
React.useEffect(() => { load(board) }, [load, board])
|
|
1992
2028
|
|
|
2029
|
+
// Подписка на Server-Sent Events (SSE) для мгновенного обновления доски (#222).
|
|
2030
|
+
React.useEffect(() => {
|
|
2031
|
+
if (typeof window === 'undefined' || typeof window.EventSource !== 'function') return undefined
|
|
2032
|
+
let es = null
|
|
2033
|
+
let reconnectTimer = null
|
|
2034
|
+
|
|
2035
|
+
const connect = () => {
|
|
2036
|
+
try {
|
|
2037
|
+
es = new window.EventSource('/dsh-kanban/events')
|
|
2038
|
+
es.addEventListener('board:change', () => {
|
|
2039
|
+
load(board)
|
|
2040
|
+
})
|
|
2041
|
+
es.addEventListener('task:change', () => {
|
|
2042
|
+
load(board)
|
|
2043
|
+
})
|
|
2044
|
+
es.onerror = () => {
|
|
2045
|
+
if (es) {
|
|
2046
|
+
try { es.close() } catch {}
|
|
2047
|
+
es = null
|
|
2048
|
+
}
|
|
2049
|
+
if (!reconnectTimer) {
|
|
2050
|
+
reconnectTimer = setTimeout(() => {
|
|
2051
|
+
reconnectTimer = null
|
|
2052
|
+
connect()
|
|
2053
|
+
}, 5000)
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
} catch {}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
connect()
|
|
2060
|
+
|
|
2061
|
+
return () => {
|
|
2062
|
+
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
2063
|
+
if (es) {
|
|
2064
|
+
try { es.close() } catch {}
|
|
2065
|
+
es = null
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
}, [load, board])
|
|
2069
|
+
|
|
1993
2070
|
// Просьба чипа «покажи вот эту». Забираем её один раз: повтор открывал
|
|
1994
2071
|
// бы окно снова после каждого закрытия.
|
|
1995
2072
|
const [wanted, setWanted] = React.useState('')
|
|
@@ -2636,12 +2713,34 @@ window.__ModuleLoader__.load({
|
|
|
2636
2713
|
className: 'dkb-modal',
|
|
2637
2714
|
onClick: () => setOpenTask(null),
|
|
2638
2715
|
}, React.createElement('div', {
|
|
2639
|
-
className: 'dkb-panel',
|
|
2716
|
+
className: 'dkb-panel dkb-panelWide',
|
|
2640
2717
|
role: 'dialog', 'aria-modal': 'true',
|
|
2641
2718
|
onClick: (e) => e.stopPropagation(),
|
|
2642
2719
|
},
|
|
2643
2720
|
// Шапка: чьё это, что за работа, и лишь потом название. Тот же
|
|
2644
2721
|
// порядок, что на карточке, — глаз не должен переучиваться.
|
|
2722
|
+
React.createElement('div', { className: 'dkb-row' },
|
|
2723
|
+
React.createElement('label', { className: 'dkb-label' }, t('reasoning.label')),
|
|
2724
|
+
React.createElement('select', {
|
|
2725
|
+
className: 'dkb-select',
|
|
2726
|
+
value: openTask.reasoningEffort || '',
|
|
2727
|
+
onChange: async (e) => {
|
|
2728
|
+
const val = e.target.value
|
|
2729
|
+
setOpenTask({ ...openTask, reasoningEffort: val })
|
|
2730
|
+
try {
|
|
2731
|
+
await api('/task/' + encodeURIComponent(openTask.id) + '/update', {
|
|
2732
|
+
method: 'POST',
|
|
2733
|
+
body: JSON.stringify({ reasoningEffort: val }),
|
|
2734
|
+
})
|
|
2735
|
+
} catch {}
|
|
2736
|
+
},
|
|
2737
|
+
},
|
|
2738
|
+
React.createElement('option', { value: '' }, t('reasoning.default')),
|
|
2739
|
+
React.createElement('option', { value: 'low' }, t('reasoning.low')),
|
|
2740
|
+
React.createElement('option', { value: 'medium' }, t('reasoning.medium')),
|
|
2741
|
+
React.createElement('option', { value: 'high' }, t('reasoning.high')),
|
|
2742
|
+
),
|
|
2743
|
+
),
|
|
2645
2744
|
React.createElement('div', { className: 'dkb-row' },
|
|
2646
2745
|
React.createElement('div', { className: 'dkb-taskHead', style: { flex: 1 } },
|
|
2647
2746
|
taskRef(openTask, t),
|
package/lib/config.js
CHANGED
|
@@ -81,6 +81,7 @@ export const CONFIG_DEFAULTS = {
|
|
|
81
81
|
announceToAgent: true,
|
|
82
82
|
maxConcurrentSessions: 3,
|
|
83
83
|
workspaceClaimBoundaries: true,
|
|
84
|
+
autoSyncSessions: false,
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
/** Пояснения к полям; повторно используются схемой и карточкой настроек. */
|
|
@@ -109,6 +110,7 @@ export const CONFIG_HINTS = {
|
|
|
109
110
|
permissionGateEnabled: 'Требовать подтверждения человеком для запуска задач с повышенными правами.',
|
|
110
111
|
sessionDefaultPermission: 'Базовый уровень прав без подтверждения. Права выше этого требуют одобрения.',
|
|
111
112
|
announceToAgent: 'Добавлять секцию о доске задач в системный промпт агентов.',
|
|
113
|
+
autoSyncSessions: 'Автоматически создавать карточки задач для обычных сессий чата DSH.',
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
/** Поле настроек с пределом числа карточек; задан не у всех колонок. */
|
package/lib/events.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Broadcaster для Server-Sent Events (SSE) (#222).
|
|
2
|
+
|
|
3
|
+
export class KanbanEventHub {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
this.clients = new Set()
|
|
6
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs || 15000
|
|
7
|
+
this.heartbeatTimer = null
|
|
8
|
+
this._startHeartbeat()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
_startHeartbeat() {
|
|
12
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
|
|
13
|
+
this.heartbeatTimer = setInterval(() => {
|
|
14
|
+
this._pingAll()
|
|
15
|
+
}, this.heartbeatIntervalMs)
|
|
16
|
+
if (this.heartbeatTimer.unref) {
|
|
17
|
+
this.heartbeatTimer.unref()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_pingAll() {
|
|
22
|
+
for (const client of this.clients) {
|
|
23
|
+
try {
|
|
24
|
+
client.write(':keepalive\n\n')
|
|
25
|
+
} catch {
|
|
26
|
+
this.clients.delete(client)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
handleSseRequest(req, res) {
|
|
32
|
+
res.writeHead(200, {
|
|
33
|
+
'Content-Type': 'text/event-stream',
|
|
34
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
35
|
+
'Connection': 'keep-alive',
|
|
36
|
+
'X-Accel-Buffering': 'no',
|
|
37
|
+
'Access-Control-Allow-Origin': '*',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
res.write(':connected\n\n')
|
|
41
|
+
this.clients.add(res)
|
|
42
|
+
|
|
43
|
+
const cleanup = () => {
|
|
44
|
+
this.clients.delete(res)
|
|
45
|
+
try { res.end() } catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (typeof req.on === 'function') req.on('close', cleanup)
|
|
49
|
+
if (typeof req.on === 'function') req.on('error', cleanup)
|
|
50
|
+
if (typeof res.on === 'function') res.on('error', cleanup)
|
|
51
|
+
|
|
52
|
+
return cleanup
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
broadcast(eventType, payload = {}) {
|
|
56
|
+
const data = JSON.stringify(payload)
|
|
57
|
+
const message = `event: ${eventType}\ndata: ${data}\n\n`
|
|
58
|
+
let sentCount = 0
|
|
59
|
+
|
|
60
|
+
for (const client of this.clients) {
|
|
61
|
+
try {
|
|
62
|
+
client.write(message)
|
|
63
|
+
sentCount++
|
|
64
|
+
} catch {
|
|
65
|
+
this.clients.delete(client)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return sentCount
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getClientCount() {
|
|
73
|
+
return this.clients.size
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
closeAll() {
|
|
77
|
+
if (this.heartbeatTimer) {
|
|
78
|
+
clearInterval(this.heartbeatTimer)
|
|
79
|
+
this.heartbeatTimer = null
|
|
80
|
+
}
|
|
81
|
+
for (const client of this.clients) {
|
|
82
|
+
try {
|
|
83
|
+
client.write('event: close\ndata: {}\n\n')
|
|
84
|
+
client.end()
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
this.clients.clear()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const eventHub = new KanbanEventHub()
|
package/lib/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
MAX_BODY_BYTES, isTrustedRequest, parseTaskPath,
|
|
23
23
|
buildBoard, applyMove, createTask, updateTask, deleteTask, appendNote, setArchived, listArchive,
|
|
24
24
|
taskBody,
|
|
25
|
-
taskLog, taskBySession, exportBoard, importBoard, assignTask, boardMetrics,
|
|
25
|
+
taskLog, taskBySession, exportBoard, importBoard, exportTasksCsv, assignTask, boardMetrics,
|
|
26
26
|
confirmPermission,
|
|
27
27
|
} from './routes.js'
|
|
28
28
|
import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
|
|
@@ -51,6 +51,8 @@ import { computeNextRun } from './cron.js'
|
|
|
51
51
|
import { PowerInhibitor } from './power.js'
|
|
52
52
|
import { formatProgressDump, parseProgressDump } from './progress-dump.js'
|
|
53
53
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
54
|
+
import { eventHub } from './events.js'
|
|
55
|
+
import { TASK_TEMPLATES } from './templates.js'
|
|
54
56
|
|
|
55
57
|
export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
|
|
56
58
|
|
|
@@ -264,6 +266,49 @@ export function apply(ctx, config) {
|
|
|
264
266
|
// Признак ожидания, исходы сессий и PROGRESSDUMP по событиям сессии.
|
|
265
267
|
ctx.effect(() => ctx.on('session/event', (session, event) => {
|
|
266
268
|
try {
|
|
269
|
+
// Автосинхронизация сессий чата DSH в карточки канбана (#225)
|
|
270
|
+
const sessionIdStr = String(session?.id ?? '')
|
|
271
|
+
if (
|
|
272
|
+
getConfig().autoSyncSessions === true &&
|
|
273
|
+
session &&
|
|
274
|
+
!session.isSubagent &&
|
|
275
|
+
session.origin !== 'subagent' &&
|
|
276
|
+
session.parentId === undefined &&
|
|
277
|
+
session.meta?.origin !== 'subagent' &&
|
|
278
|
+
!sessionIdStr.startsWith('kanban-') &&
|
|
279
|
+
!sessionIdStr.startsWith('kanban_')
|
|
280
|
+
) {
|
|
281
|
+
let existingTask = store?.findTaskBySession?.(sessionIdStr)
|
|
282
|
+
if (!existingTask && (event?.type === 'turn/start' || event?.type === 'session/created' || event?.type === 'input/user' || event?.type === 'message')) {
|
|
283
|
+
const userText = (event?.content?.[0]?.text ?? event?.text ?? '').trim()
|
|
284
|
+
const sessionTitle = session.title || session.name || (userText ? userText.slice(0, 50) : `Диалог ${sessionIdStr.slice(0, 8)}`)
|
|
285
|
+
const createdTask = store.createTask({
|
|
286
|
+
title: sessionTitle,
|
|
287
|
+
body: userText || `Автоматически создано из сессии чата ${sessionIdStr}`,
|
|
288
|
+
column: 'in-progress',
|
|
289
|
+
board: 'main',
|
|
290
|
+
sessionId: sessionIdStr,
|
|
291
|
+
labels: ['chat-sync'],
|
|
292
|
+
})
|
|
293
|
+
if (createdTask) {
|
|
294
|
+
existingTask = createdTask
|
|
295
|
+
eventHub.broadcast('task:change', { taskId: createdTask.id, action: 'created' })
|
|
296
|
+
}
|
|
297
|
+
} else if (existingTask) {
|
|
298
|
+
if (event?.type === 'turn/end' || event?.type === 'turn/completed') {
|
|
299
|
+
if (existingTask.column === 'in-progress') {
|
|
300
|
+
store.moveTask(existingTask.id, { column: 'review' })
|
|
301
|
+
eventHub.broadcast('task:change', { taskId: existingTask.id, action: 'moved', toCol: 'review' })
|
|
302
|
+
}
|
|
303
|
+
} else if (event?.type === 'turn/error' || event?.type === 'session/error') {
|
|
304
|
+
if (existingTask.column === 'in-progress') {
|
|
305
|
+
store.moveTask(existingTask.id, { column: 'todo' })
|
|
306
|
+
eventHub.broadcast('task:change', { taskId: existingTask.id, action: 'moved', toCol: 'todo' })
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
267
312
|
const text = event?.content?.[0]?.text ?? event?.text ?? ''
|
|
268
313
|
const res = handleSessionEvent({
|
|
269
314
|
store,
|
|
@@ -725,6 +770,38 @@ export function apply(ctx, config) {
|
|
|
725
770
|
handler: async (req, res) => { reply(res, { login: await whoAmI() }) },
|
|
726
771
|
}), 'dsh-kanban: кто мы для Gitea')
|
|
727
772
|
|
|
773
|
+
ctx.effect(() => ctx.webServer.register({
|
|
774
|
+
kind: 'exact',
|
|
775
|
+
path: '/dsh-kanban/export.csv',
|
|
776
|
+
handler: async (req, res) => {
|
|
777
|
+
const u = urlOf(req)
|
|
778
|
+
const board = u.searchParams.get('board') || 'main'
|
|
779
|
+
const csv = exportTasksCsv({ store, board })
|
|
780
|
+
res.writeHead(200, {
|
|
781
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
782
|
+
'Content-Disposition': `attachment; filename="kanban-${board}-${new Date().toISOString().slice(0, 10)}.csv"`,
|
|
783
|
+
'Cache-Control': 'no-store',
|
|
784
|
+
})
|
|
785
|
+
res.end(csv)
|
|
786
|
+
},
|
|
787
|
+
}), 'dsh-kanban: экспорт в CSV')
|
|
788
|
+
|
|
789
|
+
ctx.effect(() => ctx.webServer.register({
|
|
790
|
+
kind: 'exact',
|
|
791
|
+
path: '/dsh-kanban/events',
|
|
792
|
+
handler: (req, res) => {
|
|
793
|
+
eventHub.handleSseRequest(req, res)
|
|
794
|
+
},
|
|
795
|
+
}), 'dsh-kanban: поток живых событий SSE')
|
|
796
|
+
|
|
797
|
+
ctx.effect(() => ctx.webServer.register({
|
|
798
|
+
kind: 'exact',
|
|
799
|
+
path: '/dsh-kanban/templates',
|
|
800
|
+
handler: (req, res) => {
|
|
801
|
+
reply(res, { templates: TASK_TEMPLATES })
|
|
802
|
+
},
|
|
803
|
+
}), 'dsh-kanban: шаблоны задач')
|
|
804
|
+
|
|
728
805
|
ctx.effect(() => ctx.webServer.register({
|
|
729
806
|
kind: 'exact',
|
|
730
807
|
path: '/dsh-kanban/snapshot',
|
|
@@ -857,6 +934,7 @@ export function apply(ctx, config) {
|
|
|
857
934
|
detail: acted === undefined ? '' : (MOVE_DETAIL[acted] ?? ''),
|
|
858
935
|
})
|
|
859
936
|
if (out?.task !== undefined) {
|
|
937
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'moved', toCol: out.task.column })
|
|
860
938
|
queueOutbound(id, out.task.column)
|
|
861
939
|
if (acted !== undefined) out.command = acted
|
|
862
940
|
if (input.column === 'cleanup' && was?.worktree) {
|
package/lib/routes.js
CHANGED
|
@@ -427,3 +427,71 @@ export function importBoard({ store, input }) {
|
|
|
427
427
|
|
|
428
428
|
return { imported, skipped, backup: backupMeta?.backup }
|
|
429
429
|
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Экспорт задач в Excel-совместимый CSV с UTF-8 BOM (#223).
|
|
433
|
+
*
|
|
434
|
+
* @param {{store: object, board?: string}} options
|
|
435
|
+
* @returns {string} CSV-строка с префиксом \uFEFF
|
|
436
|
+
*/
|
|
437
|
+
export function exportTasksCsv({ store, board = 'main' }) {
|
|
438
|
+
const tasks = store.listTasks({ board })
|
|
439
|
+
const headers = [
|
|
440
|
+
'ID',
|
|
441
|
+
'Заголовок',
|
|
442
|
+
'Колонка',
|
|
443
|
+
'Приоритет',
|
|
444
|
+
'Репозиторий',
|
|
445
|
+
'Issue #',
|
|
446
|
+
'Исполнитель',
|
|
447
|
+
'Модель',
|
|
448
|
+
'Провайдер',
|
|
449
|
+
'Глубина рассуждения',
|
|
450
|
+
'Воркспейс',
|
|
451
|
+
'Ветка',
|
|
452
|
+
'Создана',
|
|
453
|
+
'Обновлена',
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
function escapeCsv(val) {
|
|
457
|
+
if (val === null || val === undefined) return ''
|
|
458
|
+
const str = String(val)
|
|
459
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
460
|
+
return `"${str.replace(/"/g, '""')}"`
|
|
461
|
+
}
|
|
462
|
+
return str
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function formatDate(ts) {
|
|
466
|
+
if (!ts || typeof ts !== 'number') return ''
|
|
467
|
+
try {
|
|
468
|
+
return new Date(ts).toISOString().replace('T', ' ').slice(0, 19)
|
|
469
|
+
} catch {
|
|
470
|
+
return ''
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const rows = [headers.join(',')]
|
|
475
|
+
for (const t of tasks) {
|
|
476
|
+
const row = [
|
|
477
|
+
escapeCsv(t.id),
|
|
478
|
+
escapeCsv(t.title),
|
|
479
|
+
escapeCsv(t.column),
|
|
480
|
+
escapeCsv(t.priority),
|
|
481
|
+
escapeCsv(t.repo),
|
|
482
|
+
escapeCsv(t.issueNumber ?? ''),
|
|
483
|
+
escapeCsv(t.assignee),
|
|
484
|
+
escapeCsv(t.model),
|
|
485
|
+
escapeCsv(t.provider),
|
|
486
|
+
escapeCsv(t.reasoningEffort ?? ''),
|
|
487
|
+
escapeCsv(t.workspaceId),
|
|
488
|
+
escapeCsv(t.branch),
|
|
489
|
+
escapeCsv(formatDate(t.createdAt)),
|
|
490
|
+
escapeCsv(formatDate(t.updatedAt)),
|
|
491
|
+
]
|
|
492
|
+
rows.push(row.join(','))
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Префикс UTF-8 BOM для Microsoft Excel
|
|
496
|
+
return '\uFEFF' + rows.join('\r\n')
|
|
497
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
48
48
|
permission TEXT NOT NULL DEFAULT '',
|
|
49
49
|
permissionConfirmedAt INTEGER NOT NULL DEFAULT 0,
|
|
50
50
|
runs TEXT NOT NULL DEFAULT '[]',
|
|
51
|
+
reasoningEffort TEXT NOT NULL DEFAULT '',
|
|
51
52
|
sessionId TEXT,
|
|
52
53
|
model TEXT,
|
|
53
54
|
provider TEXT,
|
|
@@ -97,7 +98,7 @@ const WRITABLE = [
|
|
|
97
98
|
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
|
|
98
99
|
'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
99
100
|
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
|
|
100
|
-
'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'permissionConfirmedAt', 'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt',
|
|
101
|
+
'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt', 'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt',
|
|
101
102
|
]
|
|
102
103
|
|
|
103
104
|
/** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
|
|
@@ -148,6 +149,7 @@ function rowToTask(row) {
|
|
|
148
149
|
comments: parsedComments,
|
|
149
150
|
rejectReason: rejectReason ?? '',
|
|
150
151
|
rejectedAt: Number(rejectedAt) || 0,
|
|
152
|
+
reasoningEffort: rest.reasoningEffort ?? '',
|
|
151
153
|
}
|
|
152
154
|
}
|
|
153
155
|
|
|
@@ -173,6 +175,7 @@ export function openStore({ dir }) {
|
|
|
173
175
|
['archivedAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
174
176
|
['queuedAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
175
177
|
['labelColors', "TEXT NOT NULL DEFAULT '{}'"],
|
|
178
|
+
['reasoningEffort', "TEXT NOT NULL DEFAULT ''"],
|
|
176
179
|
['author', "TEXT NOT NULL DEFAULT ''"],
|
|
177
180
|
['assignee', "TEXT NOT NULL DEFAULT ''"],
|
|
178
181
|
['milestone', "TEXT NOT NULL DEFAULT ''"],
|
|
@@ -355,8 +358,8 @@ export function openStore({ dir }) {
|
|
|
355
358
|
labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
|
|
356
359
|
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
|
|
357
360
|
cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
|
|
358
|
-
checklist, report, comments, rejectReason, rejectedAt)
|
|
359
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
361
|
+
checklist, report, comments, rejectReason, rejectedAt, reasoningEffort)
|
|
362
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
360
363
|
id, board, column, position,
|
|
361
364
|
input.title ?? '', input.body ?? '',
|
|
362
365
|
input.owner ?? null, input.repo ?? null,
|
|
@@ -385,6 +388,7 @@ export function openStore({ dir }) {
|
|
|
385
388
|
typeof input.comments === 'string' ? input.comments : JSON.stringify(input.comments ?? []),
|
|
386
389
|
input.rejectReason ?? '',
|
|
387
390
|
Number(input.rejectedAt) || 0,
|
|
391
|
+
typeof input.reasoningEffort === 'string' ? input.reasoningEffort : '',
|
|
388
392
|
)
|
|
389
393
|
return rowToTask(q.taskById.get(id))
|
|
390
394
|
},
|
package/lib/templates.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Шаблоны задач для быстрой постановки типовых работ (#220).
|
|
2
|
+
|
|
3
|
+
export const TASK_TEMPLATES = [
|
|
4
|
+
{
|
|
5
|
+
id: 'feature',
|
|
6
|
+
title: 'Новая функциональность (Feature)',
|
|
7
|
+
promptTemplate: 'Реализовать новую функциональность:\n- Цель:\n- Требования:\n- Границы изменений:',
|
|
8
|
+
checklist: [
|
|
9
|
+
{ id: '1', text: 'Архитектурный план и дизайн-контракт согласованы', required: true, completed: false },
|
|
10
|
+
{ id: '2', text: 'Код написан с соблюдением стандартов проекта', required: true, completed: false },
|
|
11
|
+
{ id: '3', text: 'Написаны автоматические модульные/интеграционные тесты', required: true, completed: false },
|
|
12
|
+
{ id: '4', text: 'Документация обновлена (README, docs/)', required: true, completed: false },
|
|
13
|
+
],
|
|
14
|
+
priority: 'medium',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'bugfix',
|
|
18
|
+
title: 'Исправление ошибки (Bugfix)',
|
|
19
|
+
promptTemplate: 'Локализовать и исправить дефект:\n- Шаги воспроизведения:\n- Ожидаемое поведение:\n- Фактическое поведение:',
|
|
20
|
+
checklist: [
|
|
21
|
+
{ id: '1', text: 'Дефект воспроизведен изолированным тестом', required: true, completed: false },
|
|
22
|
+
{ id: '2', text: 'Причина устранена без побочных эффектов', required: true, completed: false },
|
|
23
|
+
{ id: '3', text: 'Регрессионный тест добавлен в test suite', required: true, completed: false },
|
|
24
|
+
],
|
|
25
|
+
priority: 'high',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'refactor',
|
|
29
|
+
title: 'Рефакторинг и оптимизация (Refactoring)',
|
|
30
|
+
promptTemplate: 'Провести рефакторинг модуля без изменения внешнего поведения:\n- Целевой модуль:\n- Проблема архитектуры:\n- Критерии завершения:',
|
|
31
|
+
checklist: [
|
|
32
|
+
{ id: '1', text: 'Покрыто существующими тестами (100% green)', required: true, completed: false },
|
|
33
|
+
{ id: '2', text: 'Упрощена структура/устранен техдолг', required: true, completed: false },
|
|
34
|
+
{ id: '3', text: 'Производительность не деградировала', required: true, completed: false },
|
|
35
|
+
],
|
|
36
|
+
priority: 'low',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'security',
|
|
40
|
+
title: 'Аудит безопасности и зависимостей (Security)',
|
|
41
|
+
promptTemplate: 'Проверить безопасность и уязвимости зависимостей:\n- Область проверки:\n- Ревизия зависимостей:',
|
|
42
|
+
checklist: [
|
|
43
|
+
{ id: '1', text: 'Проверен supply-chain и лицензии зависимостей', required: true, completed: false },
|
|
44
|
+
{ id: '2', text: 'Устранены известные CVE', required: true, completed: false },
|
|
45
|
+
{ id: '3', text: 'Секреты и токены проверены на отсутствие утечек', required: true, completed: false },
|
|
46
|
+
],
|
|
47
|
+
priority: 'high',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'release',
|
|
51
|
+
title: 'Подготовка и выпуск релиза (Release Prep)',
|
|
52
|
+
promptTemplate: 'Подготовить релиз пакета:\n- Версия:\n- Список изменений:\n- Проверки перед публикацией:',
|
|
53
|
+
checklist: [
|
|
54
|
+
{ id: '1', text: 'Все тесты репозитория пройдены (npm test)', required: true, completed: false },
|
|
55
|
+
{ id: '2', text: 'Совместимость с ядром подтверждена (npm run compat)', required: true, completed: false },
|
|
56
|
+
{ id: '3', text: 'Размер упакованного пакета проверен (< 256 КБ)', required: true, completed: false },
|
|
57
|
+
{ id: '4', text: 'Документация и changelog дополнены', required: true, completed: false },
|
|
58
|
+
],
|
|
59
|
+
priority: 'medium',
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
export function getTemplateById(id) {
|
|
64
|
+
return TASK_TEMPLATES.find((t) => t.id === id)
|
|
65
|
+
}
|
package/lib/worktree.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
import { execFile } from 'node:child_process'
|
|
15
15
|
import { promisify } from 'node:util'
|
|
16
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
16
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
17
|
+
import { basename, relative } from 'node:path'
|
|
17
18
|
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
18
19
|
|
|
19
20
|
const execFileAsync = promisify(execFile)
|
|
@@ -526,3 +527,98 @@ export async function mergeTaskWorktree({
|
|
|
526
527
|
|
|
527
528
|
return { success: true, merged: true, targetBranch: target }
|
|
528
529
|
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Рекурсивное обнаружение всех git-репозиториев в каталоге воркспейса (#210).
|
|
534
|
+
*
|
|
535
|
+
* @param {string} rootDir Корневой каталог для поиска
|
|
536
|
+
* @param {{maxDepth?: number, maxRepos?: number, gitRunner?: Function}} options
|
|
537
|
+
* @returns {Promise<Array<{relPath: string, fullPath: string, name: string}>>}
|
|
538
|
+
*/
|
|
539
|
+
export async function findWorkspaceGitRepos(rootDir, options = {}) {
|
|
540
|
+
const maxDepth = options.maxDepth ?? 3
|
|
541
|
+
const maxRepos = options.maxRepos ?? 20
|
|
542
|
+
const gitRunner = options.gitRunner ?? defaultGitRunner
|
|
543
|
+
|
|
544
|
+
if (!rootDir || !existsSync(rootDir)) return []
|
|
545
|
+
|
|
546
|
+
const results = []
|
|
547
|
+
|
|
548
|
+
async function scan(dir, depth) {
|
|
549
|
+
if (depth > maxDepth || results.length >= maxRepos) return
|
|
550
|
+
try {
|
|
551
|
+
const isRepo = await isGitRepo(dir, gitRunner)
|
|
552
|
+
if (isRepo) {
|
|
553
|
+
const rel = relative(rootDir, dir) || '.'
|
|
554
|
+
results.push({
|
|
555
|
+
relPath: rel,
|
|
556
|
+
fullPath: resolve(dir),
|
|
557
|
+
name: basename(resolve(dir)),
|
|
558
|
+
})
|
|
559
|
+
// Если это репозиторий (не root), глубже в него обычно не сканируем (кроме субмодулей если нужно)
|
|
560
|
+
if (depth > 0) return
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
564
|
+
for (const entry of entries) {
|
|
565
|
+
if (!entry.isDirectory()) continue
|
|
566
|
+
if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.worktrees') continue
|
|
567
|
+
await scan(join(dir, entry.name), depth + 1)
|
|
568
|
+
}
|
|
569
|
+
} catch {
|
|
570
|
+
// Игнорируем недоступные директории
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
await scan(rootDir, 0)
|
|
575
|
+
return results
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Подготовка мультирепозиторного зеркалирования для воркспейса с несколькими git-репозиториями (#210).
|
|
580
|
+
*
|
|
581
|
+
* @param {{task: object, workspacePath: string, gitRunner?: Function, config?: object}} options
|
|
582
|
+
* @returns {Promise<{mirrors: Array<{repo: string, worktreePath: string, branch: string}>, errors?: Array<string>}>}
|
|
583
|
+
*/
|
|
584
|
+
export async function prepareMultiRepoMirror({ task, workspacePath, gitRunner = defaultGitRunner, config }) {
|
|
585
|
+
if (!workspacePath || !existsSync(workspacePath)) {
|
|
586
|
+
return { mirrors: [], errors: ['workspace-path-not-found'] }
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const repos = await findWorkspaceGitRepos(workspacePath, { gitRunner })
|
|
590
|
+
const mirrors = []
|
|
591
|
+
const errors = []
|
|
592
|
+
const branchName = resolveTaskBranch(task)
|
|
593
|
+
|
|
594
|
+
for (const repo of repos) {
|
|
595
|
+
try {
|
|
596
|
+
const mirrorRoot = resolve(workspacePath, '.dsh-worktrees', task.id, repo.relPath === '.' ? 'root' : repo.relPath)
|
|
597
|
+
const existing = await listWorktrees(repo.fullPath, gitRunner)
|
|
598
|
+
if (existing.includes(mirrorRoot)) {
|
|
599
|
+
mirrors.push({ repo: repo.name, worktreePath: mirrorRoot, branch: branchName, reused: true })
|
|
600
|
+
continue
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
let branchExists = false
|
|
604
|
+
try {
|
|
605
|
+
await gitRunner(['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`], repo.fullPath)
|
|
606
|
+
branchExists = true
|
|
607
|
+
} catch {
|
|
608
|
+
branchExists = false
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (branchExists) {
|
|
612
|
+
await gitRunner(['worktree', 'add', mirrorRoot, branchName], repo.fullPath)
|
|
613
|
+
} else {
|
|
614
|
+
const baseRef = await determineBaseRef(repo.fullPath, gitRunner)
|
|
615
|
+
await gitRunner(['worktree', 'add', '-b', branchName, mirrorRoot, baseRef], repo.fullPath)
|
|
616
|
+
}
|
|
617
|
+
mirrors.push({ repo: repo.name, worktreePath: mirrorRoot, branch: branchName, created: true })
|
|
618
|
+
} catch (err) {
|
|
619
|
+
errors.push(`${repo.name}: ${err?.message || 'failed'}`)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return { mirrors, errors: errors.length > 0 ? errors : undefined }
|
|
624
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|