@goodandready/dsh-kanban 0.1.22 → 0.1.24
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/README.md +32 -0
- package/docs/design/DESIGN.md +52 -0
- package/lib/client.js +89 -1
- package/lib/filters.js +14 -0
- package/lib/import.js +15 -0
- package/lib/index.js +15 -1
- package/lib/launcher.js +16 -2
- package/lib/metrics.js +127 -0
- package/lib/routes.js +43 -0
- package/lib/store.js +11 -3
- package/lib/sync.js +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -216,6 +216,32 @@ npm test
|
|
|
216
216
|
|
|
217
217
|
---
|
|
218
218
|
|
|
219
|
+
### 8. Metrics: Where the Work Stands
|
|
220
|
+
|
|
221
|
+
The transition log has been filling up since day one, but it could only be read
|
|
222
|
+
one task at a time. The **Metrics** screen adds it up: how long tasks sit in each
|
|
223
|
+
column (median first — one task forgotten for half a year drags the average
|
|
224
|
+
until it describes nothing), how many reached Done in the last week and month,
|
|
225
|
+
and which tasks have been sitting still longer than the threshold.
|
|
226
|
+
|
|
227
|
+
Sitting time counts from entering the column, not from creation: a task can be
|
|
228
|
+
old and still move every day. Nothing is written — a metric that writes stops
|
|
229
|
+
being an observation and becomes another source of truth to disagree with.
|
|
230
|
+
|
|
231
|
+
### 9. Milestones and Reviving a Dead Session
|
|
232
|
+
|
|
233
|
+
The milestone travels with the issue and becomes a filter dimension of its own,
|
|
234
|
+
so "what is left before 0.2.0" is answered on the board rather than in Gitea.
|
|
235
|
+
Removing a milestone is news too, so an emptied field is synced like any other
|
|
236
|
+
change. Setting a milestone from the board is deliberately out of scope for now:
|
|
237
|
+
reading first, writing later, or we get a second place where it is edited.
|
|
238
|
+
|
|
239
|
+
A task whose session died — the harness restarted, the agent is gone — shows
|
|
240
|
+
"stopped". Its card now offers **resume work**: the old session is tried first,
|
|
241
|
+
and only if it cannot be revived does a new one start. The board says which of
|
|
242
|
+
the two happened, because "continued" and "started over" differ by whether the
|
|
243
|
+
conversation still exists.
|
|
244
|
+
|
|
219
245
|
## 🔌 Core Compatibility Check
|
|
220
246
|
|
|
221
247
|
A named import of an export the installed core no longer has is a `SyntaxError`
|
|
@@ -234,6 +260,12 @@ compares the requested names against the actual exports. It is deliberately not
|
|
|
234
260
|
part of `npm test`: the test suite runs without a core and without a profile.
|
|
235
261
|
Run it on the machine where the harness lives, before publishing.
|
|
236
262
|
|
|
263
|
+
## 💻 Cross-Platform Path Handling
|
|
264
|
+
|
|
265
|
+
Task working directories (`resolveCwd`) respect operating system path conventions.
|
|
266
|
+
On Windows, repository root fencing accounts for drive letters and backslash path
|
|
267
|
+
separators, ensuring full test and runtime compatibility across POSIX and Windows.
|
|
268
|
+
|
|
237
269
|
## 📄 License
|
|
238
270
|
|
|
239
271
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# DESIGN.md — dsh-kanban
|
|
2
|
+
|
|
3
|
+
## Product / Purpose
|
|
4
|
+
- Назначение: Интегрированная доска задач для DeepSeek Harness с двусторонней сверкой с Gitea, автоматическим подхватом сессий агентов и управлением жизненным циклом.
|
|
5
|
+
- Аудитория: Разработчики и операторы DeepSeek Harness, ведущие проектные задачи и сессии агентов.
|
|
6
|
+
- Статус: Production, версия 0.1.23+.
|
|
7
|
+
|
|
8
|
+
## User Surfaces
|
|
9
|
+
- Web/UI: Экран доски в центральной колонке интерфейса DSH (mountBoard), выдвигающаяся панель задач, фильтры, группировки (по проектам, по исполнителю), метрики переходов.
|
|
10
|
+
- DSH UI / settings / slots:
|
|
11
|
+
- Карточка настроек в слоте settings.plugin.item (пространство dsh-kanban).
|
|
12
|
+
- Чип активной задачи в conversation.session.header.utilities.
|
|
13
|
+
- Пункт бокового меню для вызова экрана доски.
|
|
14
|
+
- API: HTTP эндпоинты /dsh-kanban/* (board, snapshot, task, sync, webhook, metrics, whoami).
|
|
15
|
+
- CLI: node tools/compat.mjs (проверка совместимости экспортов ядра).
|
|
16
|
+
- Документация: README.md, docs/README.ru.md, docs/README.zh.md, index.md.
|
|
17
|
+
|
|
18
|
+
## Visual Direction
|
|
19
|
+
- Атмосфера: Строгий утилитарный инструментальный интерфейс в едином стиле темы DeepSeek Harness.
|
|
20
|
+
- Утверждённые референсы и что из них берём: Системные карточки DSH (стили заголовков, отступы, шрифты, цветовые токены --dsw-alias-*).
|
|
21
|
+
- Не копировать: Внешние неинтегрированные темы, разнородные цвета рамок и теней.
|
|
22
|
+
|
|
23
|
+
## Foundations
|
|
24
|
+
- Цвета и роли: Использование CSS-переменных DSH темы (--dsw-alias-bg-layer-3, --dsw-alias-border-l2, --dsw-alias-label-primary, --dsw-alias-label-secondary).
|
|
25
|
+
- Типографика: Системный стек шрифтов DSH (inherit, 13-14px базовый размер, 11-12px для бейджей и служебных меток).
|
|
26
|
+
- Сетка, отступы, responsive: 5 стандартных колонок (backlog, ready, progress, review, done) с горизонтальным скроллом и адаптивными карточками.
|
|
27
|
+
- Accessibility: Нативные кнопки button, role="status" для сообщений, семантичные поля ввода с aria-label/label.
|
|
28
|
+
|
|
29
|
+
## Components And States
|
|
30
|
+
- Компоненты: BoardScreen, Column, Card, TaskDrawer, KanbanSettingsCard, TaskChip, MetricsModal.
|
|
31
|
+
- Loading / empty / error / success:
|
|
32
|
+
- Загрузка: состояние скелетона/лоадера без блокировки скролла.
|
|
33
|
+
- Пустые колонки: пунктирная область перетаскивания.
|
|
34
|
+
- Ошибки: всплывающий баннер с ключом локализации t('error.' + err).
|
|
35
|
+
- Формы, валидация и действия: Валидация обязательных полей при создании задачи, блокировка повторных кликов при сохранении.
|
|
36
|
+
|
|
37
|
+
## User Flows
|
|
38
|
+
- Критические сценарии:
|
|
39
|
+
1. Создание задачи / подхват из Gitea Issue.
|
|
40
|
+
2. Перемещение карточки между колонками человеком или через инструмент агента board_move.
|
|
41
|
+
3. Запуск сессии агента по карточке с автоматическим назначением ответственного.
|
|
42
|
+
4. Автоматическое обновление статуса (waiting) по событиям ядра (session/event).
|
|
43
|
+
5. Экспорт и импорт снимка доски в JSON.
|
|
44
|
+
|
|
45
|
+
## Do / Don't
|
|
46
|
+
- Do: Использовать только токены темы --dsw-alias-*, очищать все слушатели событий при размонтировании, соблюдать порядок хуков React.
|
|
47
|
+
- Don't: Не переводить карточку в done агентом (колонка done фиксируется только по факту закрытия issue и ветки).
|
|
48
|
+
|
|
49
|
+
## Locked Design Decisions
|
|
50
|
+
- 2026-08-20 — Карточка настроек монтируется в settings.plugin.item с резервом в settings.section.
|
|
51
|
+
- 2026-08-25 — Использование дробной индексации ($O(1)$) для позиционирования карточек.
|
|
52
|
+
- 2026-09-02 — Снимок доски объединён на маршруте /dsh-kanban/snapshot (GET: выгрузка, POST: загрузка).
|
package/lib/client.js
CHANGED
|
@@ -950,6 +950,14 @@ window.__ModuleLoader__.load({
|
|
|
950
950
|
'error.repo-not-created': 'The repository was not created; nothing else was done.',
|
|
951
951
|
'error.issue-not-created': 'The issue was not created. If the repository was new, it stayed — nothing was deleted.',
|
|
952
952
|
'board.archive': 'Archive',
|
|
953
|
+
'board.metrics': 'Metrics',
|
|
954
|
+
'metrics.hint': '{n} tasks on the board · done in a week: {week} · in a month: {month}',
|
|
955
|
+
'metrics.columns': 'Time spent in a column',
|
|
956
|
+
'metrics.median': 'median {time}',
|
|
957
|
+
'metrics.mean': 'average {time}',
|
|
958
|
+
'metrics.stale': 'Sitting still longer than {days} days',
|
|
959
|
+
'metrics.noStale': 'Nothing has been sitting that long.',
|
|
960
|
+
'metrics.empty': 'No transitions yet — nothing to measure.',
|
|
953
961
|
'board.export': 'Download',
|
|
954
962
|
'board.import': 'Import',
|
|
955
963
|
'board.exportHint': 'Download the whole board as JSON',
|
|
@@ -984,6 +992,9 @@ window.__ModuleLoader__.load({
|
|
|
984
992
|
'board.pickMove': 'Move to…',
|
|
985
993
|
'board.someFailed': 'Done for {done} of {total}; the rest refused.',
|
|
986
994
|
'card.stop': 'stop',
|
|
995
|
+
'card.revive': 'resume work',
|
|
996
|
+
'revive.same': 'The old session came back — the chat is where you left it.',
|
|
997
|
+
'revive.fresh': 'The old session was gone, so a new one started; the previous chat is not there any more.',
|
|
987
998
|
'card.pack': 'batch {at} of {total}',
|
|
988
999
|
'card.packHint': 'Started together, one session; the agent takes them in this order.',
|
|
989
1000
|
'stop.idle': 'The agent was not running — there was nothing to stop.',
|
|
@@ -1206,6 +1217,14 @@ window.__ModuleLoader__.load({
|
|
|
1206
1217
|
'error.repo-not-created': '仓库没有创建,其他动作也未执行。',
|
|
1207
1218
|
'error.issue-not-created': 'issue 没有创建。如果仓库是新建的,它仍然保留——没有删除任何东西。',
|
|
1208
1219
|
'board.archive': '归档',
|
|
1220
|
+
'board.metrics': '指标',
|
|
1221
|
+
'metrics.hint': '看板上有 {n} 个任务 · 一周完成 {week} 个 · 一个月完成 {month} 个',
|
|
1222
|
+
'metrics.columns': '在每一列停留的时间',
|
|
1223
|
+
'metrics.median': '中位数 {time}',
|
|
1224
|
+
'metrics.mean': '平均 {time}',
|
|
1225
|
+
'metrics.stale': '停滞超过 {days} 天',
|
|
1226
|
+
'metrics.noStale': '没有停滞这么久的任务。',
|
|
1227
|
+
'metrics.empty': '还没有流转记录,无从统计。',
|
|
1209
1228
|
'board.export': '下载',
|
|
1210
1229
|
'board.import': '导入',
|
|
1211
1230
|
'board.exportHint': '将整个看板下载为 JSON',
|
|
@@ -1240,6 +1259,9 @@ window.__ModuleLoader__.load({
|
|
|
1240
1259
|
'board.pickMove': '移动到…',
|
|
1241
1260
|
'board.someFailed': '{total} 个中完成了 {done} 个,其余被拒绝。',
|
|
1242
1261
|
'card.stop': '停止',
|
|
1262
|
+
'card.revive': '继续工作',
|
|
1263
|
+
'revive.same': '原会话已恢复,对话还在原处。',
|
|
1264
|
+
'revive.fresh': '原会话已不在,因此新开了一个;先前的对话不在其中。',
|
|
1243
1265
|
'card.pack': '批次 {at}/{total}',
|
|
1244
1266
|
'card.packHint': '一起启动,同一个会话;Agent 按这个顺序接手。',
|
|
1245
1267
|
'stop.idle': 'Agent 本来就没有在跑,没有什么可停。',
|
|
@@ -1620,6 +1642,12 @@ window.__ModuleLoader__.load({
|
|
|
1620
1642
|
// работу», разница лишь в том, кому и когда.
|
|
1621
1643
|
// Останавливать нечего у того, кто не идёт: пункт, который
|
|
1622
1644
|
// ничего не делает, хуже отсутствующего.
|
|
1645
|
+
task.state === 'stopped'
|
|
1646
|
+
? React.createElement('button', {
|
|
1647
|
+
type: 'button', className: 'dkb-facetRow', key: 'revive',
|
|
1648
|
+
onClick: (e) => { e.stopPropagation(); props.onMenu(''); props.onRevive(task) },
|
|
1649
|
+
}, t('card.revive'))
|
|
1650
|
+
: null,
|
|
1623
1651
|
task.state === 'running' || task.state === 'waiting'
|
|
1624
1652
|
? React.createElement('button', {
|
|
1625
1653
|
type: 'button', className: 'dkb-facetRow', key: 'stop',
|
|
@@ -1766,6 +1794,7 @@ window.__ModuleLoader__.load({
|
|
|
1766
1794
|
// выбор переживает и открытие окна, и обновление доски.
|
|
1767
1795
|
const [picked, setPicked] = React.useState({})
|
|
1768
1796
|
const [archive, setArchive] = React.useState(null)
|
|
1797
|
+
const [metrics, setMetrics] = React.useState(null)
|
|
1769
1798
|
// «Только ждущие» держим отдельно от отборов: это не значение измерения,
|
|
1770
1799
|
// а взгляд «покажи, где стоит работа», и складывается он с любым отбором.
|
|
1771
1800
|
const [onlyWaiting, setOnlyWaiting] = React.useState(false)
|
|
@@ -2080,11 +2109,22 @@ window.__ModuleLoader__.load({
|
|
|
2080
2109
|
React.createElement('option', { value: 'old' }, t('order.old')),
|
|
2081
2110
|
React.createElement('option', { value: 'new' }, t('order.new')),
|
|
2082
2111
|
),
|
|
2112
|
+
React.createElement('button', {
|
|
2113
|
+
type: 'button', className: metrics ? 'dkb-save' : 'dkb-discard',
|
|
2114
|
+
onClick: async () => {
|
|
2115
|
+
if (metrics) { setMetrics(null); return }
|
|
2116
|
+
try {
|
|
2117
|
+
setArchive(null)
|
|
2118
|
+
setMetrics(await api('/metrics?board=' + encodeURIComponent(board)))
|
|
2119
|
+
} catch (e) { setError(t(e.key || 'error.unknown')) }
|
|
2120
|
+
},
|
|
2121
|
+
}, t('board.metrics')),
|
|
2083
2122
|
React.createElement('button', {
|
|
2084
2123
|
type: 'button', className: archive ? 'dkb-save' : 'dkb-discard',
|
|
2085
2124
|
onClick: async () => {
|
|
2086
2125
|
if (archive) { setArchive(null); return }
|
|
2087
2126
|
try {
|
|
2127
|
+
setMetrics(null)
|
|
2088
2128
|
const out = await api('/archive')
|
|
2089
2129
|
setArchive(Array.isArray(out.tasks) ? out.tasks : [])
|
|
2090
2130
|
} catch (e) { setError(t(e.key || 'error.unknown')) }
|
|
@@ -2332,6 +2372,18 @@ window.__ModuleLoader__.load({
|
|
|
2332
2372
|
pack: packs[task.id],
|
|
2333
2373
|
sameSession: hoverSession !== '' && task.sessionId === hoverSession,
|
|
2334
2374
|
onHover: setHoverSession,
|
|
2375
|
+
onRevive: async (x) => {
|
|
2376
|
+
// Переиспользуем обычный запуск: он сам пробует возобновить
|
|
2377
|
+
// прежнюю сессию и только потом заводит новую. Здесь важно
|
|
2378
|
+
// сказать, что именно вышло.
|
|
2379
|
+
try {
|
|
2380
|
+
const out = await api('/task/' + encodeURIComponent(x.id) + '/start', {
|
|
2381
|
+
method: 'POST', body: JSON.stringify({ revive: true }),
|
|
2382
|
+
})
|
|
2383
|
+
setError(t(out && out.mode === 'created' ? 'revive.fresh' : 'revive.same'))
|
|
2384
|
+
await load(board)
|
|
2385
|
+
} catch (e) { setError(t(e.key || 'error.unknown')) }
|
|
2386
|
+
},
|
|
2335
2387
|
onStop: async (x) => {
|
|
2336
2388
|
try {
|
|
2337
2389
|
const out = await api('/task/' + encodeURIComponent(x.id) + '/stop', { method: 'POST', body: '{}' })
|
|
@@ -3073,10 +3125,46 @@ window.__ModuleLoader__.load({
|
|
|
3073
3125
|
)
|
|
3074
3126
|
: null
|
|
3075
3127
|
|
|
3128
|
+
// Метрика — это взгляд на журнал, а не новые данные: экран ничего не
|
|
3129
|
+
// меняет, поэтому и кнопок действий на нём нет.
|
|
3130
|
+
const metricsEl = metrics
|
|
3131
|
+
? React.createElement('div', { className: 'dkb-archive' },
|
|
3132
|
+
React.createElement('p', { className: 'dkb-hint' },
|
|
3133
|
+
t('metrics.hint', { n: metrics.tasks, week: metrics.done.week, month: metrics.done.month })),
|
|
3134
|
+
React.createElement('div', { className: 'dkb-group' }, t('metrics.columns')),
|
|
3135
|
+
metrics.columns.length === 0
|
|
3136
|
+
? React.createElement('p', { className: 'dkb-note' }, t('metrics.empty'))
|
|
3137
|
+
: React.createElement('ul', { className: 'dkb-archiveList' },
|
|
3138
|
+
metrics.columns.map((row) => React.createElement('li', {
|
|
3139
|
+
className: 'dkb-archiveRow', key: row.column,
|
|
3140
|
+
},
|
|
3141
|
+
React.createElement('span', { className: 'dkb-archiveTitle' }, columnTitle({ id: row.column }, t)),
|
|
3142
|
+
React.createElement('span', { className: 'dkb-taskRef' },
|
|
3143
|
+
t('metrics.median', { time: agoText(0, -row.median, t) })),
|
|
3144
|
+
React.createElement('span', { className: 'dkb-taskRef' },
|
|
3145
|
+
t('metrics.mean', { time: agoText(0, -row.mean, t) })),
|
|
3146
|
+
React.createElement('span', { className: 'dkb-count' }, String(row.tasks)),
|
|
3147
|
+
))),
|
|
3148
|
+
React.createElement('div', { className: 'dkb-group' },
|
|
3149
|
+
t('metrics.stale', { days: metrics.staleDays })),
|
|
3150
|
+
metrics.stale.length === 0
|
|
3151
|
+
? React.createElement('p', { className: 'dkb-note' }, t('metrics.noStale'))
|
|
3152
|
+
: React.createElement('ul', { className: 'dkb-archiveList' },
|
|
3153
|
+
metrics.stale.map((row) => React.createElement('li', {
|
|
3154
|
+
className: 'dkb-archiveRow', key: row.id,
|
|
3155
|
+
},
|
|
3156
|
+
React.createElement('span', { className: 'dkb-archiveTitle' }, row.title),
|
|
3157
|
+
React.createElement('span', { className: 'dkb-taskRef' }, columnTitle({ id: row.column }, t)),
|
|
3158
|
+
React.createElement('span', { className: 'dkb-taskRef', title: exactAt(row.since) },
|
|
3159
|
+
agoText(state.now, row.since, t)),
|
|
3160
|
+
))),
|
|
3161
|
+
)
|
|
3162
|
+
: null
|
|
3163
|
+
|
|
3076
3164
|
return React.createElement('div', { className: 'dkb-screen' },
|
|
3077
3165
|
style, bar,
|
|
3078
3166
|
error ? React.createElement('p', { className: 'dkb-failed' }, error) : null,
|
|
3079
|
-
archive ? archiveEl : columns,
|
|
3167
|
+
metrics ? metricsEl : archive ? archiveEl : columns,
|
|
3080
3168
|
panel, dialogEl, moveDialogEl, batchEl, queueEl,
|
|
3081
3169
|
)
|
|
3082
3170
|
}
|
package/lib/filters.js
CHANGED
|
@@ -14,6 +14,9 @@ export const AUTHOR = 'author'
|
|
|
14
14
|
/** Ответственный: кто взял задачу, а не кто её завёл. */
|
|
15
15
|
export const ASSIGNEE = 'assignee'
|
|
16
16
|
|
|
17
|
+
/** Выпуск, к которому задача отнесена в Gitea. */
|
|
18
|
+
export const MILESTONE = 'milestone'
|
|
19
|
+
|
|
17
20
|
/** Приоритет — локальное свойство доски, своё измерение отбора. */
|
|
18
21
|
export const PRIORITY = 'priority'
|
|
19
22
|
|
|
@@ -34,6 +37,9 @@ export const NO_AUTHOR = '—'
|
|
|
34
37
|
*/
|
|
35
38
|
export const NOBODY = '—'
|
|
36
39
|
|
|
40
|
+
/** Значение для задач вне выпусков: «когда-нибудь» — тоже ответ. */
|
|
41
|
+
export const NO_MILESTONE = '—'
|
|
42
|
+
|
|
37
43
|
/** Порядок приоритетов в отборе: срочное сверху. */
|
|
38
44
|
export const PRIORITY_ORDER = ['critical', 'high', 'medium', 'low']
|
|
39
45
|
|
|
@@ -79,6 +85,7 @@ export function facetsOf(tasks) {
|
|
|
79
85
|
for (const task of tasks ?? []) {
|
|
80
86
|
add(REPO, typeof task?.repo === 'string' ? task.repo : '')
|
|
81
87
|
add(ASSIGNEE, typeof task?.assignee === 'string' && task.assignee !== '' ? task.assignee : NOBODY)
|
|
88
|
+
add(MILESTONE, typeof task?.milestone === 'string' && task.milestone !== '' ? task.milestone : NO_MILESTONE)
|
|
82
89
|
add(AUTHOR, typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR)
|
|
83
90
|
// Приоритет живёт и полем (свои карточки), и меткой `priority/*` (задачи
|
|
84
91
|
// из Gitea). Оба попадают в одно измерение — отбор по срочности не должен
|
|
@@ -106,6 +113,9 @@ function rank(ns) {
|
|
|
106
113
|
// Ответственный идёт раньше автора: «кто делает» спрашивают чаще, чем «кто
|
|
107
114
|
// завёл».
|
|
108
115
|
if (ns === ASSIGNEE) return -2
|
|
116
|
+
// Выпуск идёт сразу за ответственным: «что осталось до релиза» спрашивают
|
|
117
|
+
// тогда же, когда «кто делает».
|
|
118
|
+
if (ns === MILESTONE) return -1.5
|
|
109
119
|
if (ns === AUTHOR) return -1
|
|
110
120
|
const at = KNOWN_ORDER.indexOf(ns)
|
|
111
121
|
return at === -1 ? KNOWN_ORDER.length : at
|
|
@@ -148,6 +158,10 @@ function hasAny(task, ns, wanted) {
|
|
|
148
158
|
const who = typeof task?.assignee === 'string' && task.assignee !== '' ? task.assignee : NOBODY
|
|
149
159
|
return wanted.includes(who)
|
|
150
160
|
}
|
|
161
|
+
if (ns === MILESTONE) {
|
|
162
|
+
const to = typeof task?.milestone === 'string' && task.milestone !== '' ? task.milestone : NO_MILESTONE
|
|
163
|
+
return wanted.includes(to)
|
|
164
|
+
}
|
|
151
165
|
if (ns === AUTHOR) {
|
|
152
166
|
const author = typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR
|
|
153
167
|
return wanted.includes(author)
|
package/lib/import.js
CHANGED
|
@@ -49,6 +49,17 @@ export function issueAssignee(issue) {
|
|
|
49
49
|
return first === undefined ? '' : first.login
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* К какому выпуску относится issue.
|
|
54
|
+
*
|
|
55
|
+
* Релизы ведут майлстоунами Gitea, а доска о них не знала: «что осталось до
|
|
56
|
+
* 0.2.0» приходилось смотреть в другом месте, хотя карточки лежат здесь.
|
|
57
|
+
*/
|
|
58
|
+
export function issueMilestone(issue) {
|
|
59
|
+
const title = issue?.milestone?.title
|
|
60
|
+
return typeof title === 'string' ? title : ''
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
/**
|
|
53
64
|
* Поля задачи из issue.
|
|
54
65
|
*
|
|
@@ -77,6 +88,7 @@ export function issueToTask(issue, { board = 'main', column = 'backlog', owner,
|
|
|
77
88
|
createdAt: issueBornAt(issue),
|
|
78
89
|
author: issueAuthor(issue),
|
|
79
90
|
assignee: issueAssignee(issue),
|
|
91
|
+
milestone: issueMilestone(issue),
|
|
80
92
|
}
|
|
81
93
|
}
|
|
82
94
|
|
|
@@ -126,6 +138,9 @@ export function refreshPatch(task, issue) {
|
|
|
126
138
|
if (issue?.assignee !== undefined || Array.isArray(issue?.assignees)) {
|
|
127
139
|
patch.assignee = issueAssignee(issue)
|
|
128
140
|
}
|
|
141
|
+
// Майлстоун снимают так же часто, как ставят, поэтому пустое значение —
|
|
142
|
+
// тоже новость, а не отсутствие новостей.
|
|
143
|
+
if ('milestone' in (issue ?? {})) patch.milestone = issueMilestone(issue)
|
|
129
144
|
return patch
|
|
130
145
|
}
|
|
131
146
|
|
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,
|
|
25
|
+
taskLog, taskBySession, exportBoard, importBoard, assignTask, boardMetrics,
|
|
26
26
|
} from './routes.js'
|
|
27
27
|
import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
|
|
28
28
|
import {
|
|
@@ -545,6 +545,20 @@ export function apply(ctx, config) {
|
|
|
545
545
|
return knownLogin
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
ctx.effect(() => ctx.webServer.register({
|
|
549
|
+
kind: 'exact',
|
|
550
|
+
path: '/dsh-kanban/metrics',
|
|
551
|
+
handler: (req, res) => {
|
|
552
|
+
const url = urlOf(req)
|
|
553
|
+
const days = Number(url.searchParams.get('staleDays'))
|
|
554
|
+
reply(res, boardMetrics({
|
|
555
|
+
store,
|
|
556
|
+
board: url.searchParams.get('board') || 'main',
|
|
557
|
+
staleDays: Number.isFinite(days) && days > 0 ? days : 14,
|
|
558
|
+
}))
|
|
559
|
+
},
|
|
560
|
+
}), 'dsh-kanban: метрики доски')
|
|
561
|
+
|
|
548
562
|
ctx.effect(() => ctx.webServer.register({
|
|
549
563
|
kind: 'exact',
|
|
550
564
|
path: '/dsh-kanban/whoami',
|
package/lib/launcher.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// Пакеты ядра здесь не импортируются: идентификатор сессии и сборка сообщения
|
|
13
13
|
// приходят параметрами. Так модуль проверяется без харнесса и без сети.
|
|
14
14
|
|
|
15
|
-
import { isAbsolute, join, resolve } from 'node:path'
|
|
15
|
+
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Хвост своей задачи — УСЛОВИЕ, а не приказ.
|
|
@@ -110,7 +110,8 @@ export function resolveCwd(task, config, cwdOf = () => process.cwd()) {
|
|
|
110
110
|
|
|
111
111
|
const target = resolve(join(root, repo))
|
|
112
112
|
const fence = resolve(root)
|
|
113
|
-
|
|
113
|
+
const fenceSep = fence.endsWith(sep) || fence.endsWith('/') ? fence : fence + sep
|
|
114
|
+
if (target !== fence && !target.startsWith(fenceSep) && !target.startsWith(fence.endsWith('/') ? fence : fence + '/')) {
|
|
114
115
|
throw new Error('имя репозитория выводит за пределы корня проектов')
|
|
115
116
|
}
|
|
116
117
|
return target
|
|
@@ -199,6 +200,19 @@ export async function obtainAgent({
|
|
|
199
200
|
* @param {string} [text] сообщение человека; пусто — встроенная заготовка
|
|
200
201
|
* @returns {{sessionId: string, mode: string, task: object}}
|
|
201
202
|
*/
|
|
203
|
+
/**
|
|
204
|
+
* Что случилось при попытке продолжить работу.
|
|
205
|
+
*
|
|
206
|
+
* «Продолжил» и «начал заново» — разные вещи, и человек обязан видеть, какая
|
|
207
|
+
* из них произошла: в первом случае переписка на месте, во втором её больше
|
|
208
|
+
* нет.
|
|
209
|
+
*
|
|
210
|
+
* @returns {'opened'|'resumed'|'created'} как в obtainAgent
|
|
211
|
+
*/
|
|
212
|
+
export function revivalKind(mode) {
|
|
213
|
+
return mode === 'opened' || mode === 'resumed' ? mode : 'created'
|
|
214
|
+
}
|
|
215
|
+
|
|
202
216
|
/**
|
|
203
217
|
* Взять задачу на себя при запуске.
|
|
204
218
|
*
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Метрики доски: взгляд на журнал переходов, а не новые данные.
|
|
2
|
+
//
|
|
3
|
+
// Журнал копится с первого дня, но прочитать его можно было только по одной
|
|
4
|
+
// задаче. Вопросы «где затык», «сколько живёт задача» и «что лежит месяц» имеют
|
|
5
|
+
// ответ в тех же записях — их надо только сложить.
|
|
6
|
+
//
|
|
7
|
+
// Ничего не записываем: метрика, которая пишет, перестаёт быть наблюдением и
|
|
8
|
+
// становится ещё одним источником правды, расходящимся с журналом.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Сколько времени задача пробыла в каждой колонке.
|
|
12
|
+
*
|
|
13
|
+
* Переход закрывает предыдущий отрезок и открывает следующий. Последний
|
|
14
|
+
* отрезок закрывается «сейчас»: задача всё ещё лежит там, и это время идёт.
|
|
15
|
+
*
|
|
16
|
+
* @param {Array<{toCol: string, at: number}>} moves переходы задачи, старые первыми
|
|
17
|
+
* @param {number} bornAt когда задача заведена — начало первого отрезка
|
|
18
|
+
* @param {number} now
|
|
19
|
+
* @returns {Object<string, number>} колонка → миллисекунды
|
|
20
|
+
*/
|
|
21
|
+
export function timeInColumns(moves, bornAt, now) {
|
|
22
|
+
const rows = (moves ?? [])
|
|
23
|
+
.filter((m) => typeof m?.at === 'number' && typeof m?.toCol === 'string')
|
|
24
|
+
.slice()
|
|
25
|
+
.sort((a, b) => a.at - b.at)
|
|
26
|
+
|
|
27
|
+
const out = {}
|
|
28
|
+
// До первого перехода задача лежит там, где её завели. Колонку начала берём
|
|
29
|
+
// из первого перехода: `fromCol` и есть то, откуда она уехала.
|
|
30
|
+
let col = rows.length > 0 && typeof rows[0].fromCol === 'string' ? rows[0].fromCol : 'backlog'
|
|
31
|
+
let since = typeof bornAt === 'number' ? bornAt : (rows[0]?.at ?? now)
|
|
32
|
+
|
|
33
|
+
const add = (where, ms) => {
|
|
34
|
+
if (ms <= 0) return
|
|
35
|
+
out[where] = (out[where] ?? 0) + ms
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const move of rows) {
|
|
39
|
+
add(col, move.at - since)
|
|
40
|
+
col = move.toCol
|
|
41
|
+
since = move.at
|
|
42
|
+
}
|
|
43
|
+
add(col, now - since)
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Медиана: половина значений меньше, половина больше. */
|
|
48
|
+
export function median(values) {
|
|
49
|
+
const rows = (values ?? []).filter((x) => typeof x === 'number').slice().sort((a, b) => a - b)
|
|
50
|
+
if (rows.length === 0) return 0
|
|
51
|
+
const mid = Math.floor(rows.length / 2)
|
|
52
|
+
return rows.length % 2 === 1 ? rows[mid] : Math.round((rows[mid - 1] + rows[mid]) / 2)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Среднее; пустой набор даёт ноль, а не деление на ноль. */
|
|
56
|
+
export function mean(values) {
|
|
57
|
+
const rows = (values ?? []).filter((x) => typeof x === 'number')
|
|
58
|
+
if (rows.length === 0) return 0
|
|
59
|
+
return Math.round(rows.reduce((sum, x) => sum + x, 0) / rows.length)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Сводка по колонкам: сколько в среднем и по медиане в них лежат.
|
|
64
|
+
*
|
|
65
|
+
* Медиана важнее среднего: одна задача, забытая на полгода, сдвигает среднее
|
|
66
|
+
* так, что оно перестаёт описывать хоть что-нибудь.
|
|
67
|
+
*
|
|
68
|
+
* @param {Array<{task: object, moves: object[]}>} rows
|
|
69
|
+
* @returns {Array<{column: string, mean: number, median: number, tasks: number}>}
|
|
70
|
+
*/
|
|
71
|
+
export function columnStats(rows, now) {
|
|
72
|
+
const byColumn = new Map()
|
|
73
|
+
for (const row of rows ?? []) {
|
|
74
|
+
const spent = timeInColumns(row.moves, row.task?.createdAt, now)
|
|
75
|
+
for (const [column, ms] of Object.entries(spent)) {
|
|
76
|
+
if (!byColumn.has(column)) byColumn.set(column, [])
|
|
77
|
+
byColumn.get(column).push(ms)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [...byColumn.entries()]
|
|
81
|
+
.map(([column, values]) => ({
|
|
82
|
+
column,
|
|
83
|
+
mean: mean(values),
|
|
84
|
+
median: median(values),
|
|
85
|
+
tasks: values.length,
|
|
86
|
+
}))
|
|
87
|
+
.sort((a, b) => b.median - a.median)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Задачи, залежавшиеся в своей колонке дольше порога.
|
|
92
|
+
*
|
|
93
|
+
* Считаем от входа в колонку, а не от заведения: задача может быть старой и
|
|
94
|
+
* при этом двигаться каждый день.
|
|
95
|
+
*
|
|
96
|
+
* @returns {Array<{id: string, title: string, column: string, since: number}>}
|
|
97
|
+
*/
|
|
98
|
+
export function stale(tasks, now, thresholdMs) {
|
|
99
|
+
if (!(thresholdMs > 0)) return []
|
|
100
|
+
return (tasks ?? [])
|
|
101
|
+
.filter((task) => {
|
|
102
|
+
const at = typeof task?.columnAt === 'number' && task.columnAt > 0 ? task.columnAt : task?.createdAt
|
|
103
|
+
return typeof at === 'number' && now - at >= thresholdMs
|
|
104
|
+
})
|
|
105
|
+
.map((task) => ({
|
|
106
|
+
id: task.id,
|
|
107
|
+
title: task.title,
|
|
108
|
+
column: task.column,
|
|
109
|
+
since: (typeof task.columnAt === 'number' && task.columnAt > 0 ? task.columnAt : task.createdAt),
|
|
110
|
+
}))
|
|
111
|
+
.sort((a, b) => a.since - b.since)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Сколько задач доехало до «Выполнено» за период.
|
|
116
|
+
*
|
|
117
|
+
* Считаем по переходам, а не по текущей колонке: задача, ушедшая в архив,
|
|
118
|
+
* всё равно была сделана.
|
|
119
|
+
*/
|
|
120
|
+
export function doneWithin(rows, from, to) {
|
|
121
|
+
let count = 0
|
|
122
|
+
for (const row of rows ?? []) {
|
|
123
|
+
const hit = (row.moves ?? []).some((m) => m?.toCol === 'done' && m.at >= from && m.at <= to)
|
|
124
|
+
if (hit) count += 1
|
|
125
|
+
}
|
|
126
|
+
return count
|
|
127
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { COLUMN_ORDER, columnsOf, wipLimitField, columnNamesOf } from './config.js'
|
|
12
12
|
import { parsePlan, planItems, planProgress, taskState, queuePosition } from './plan.js'
|
|
13
|
+
import { columnStats, doneWithin, stale } from './metrics.js'
|
|
13
14
|
import { isStale } from './time.js'
|
|
14
15
|
import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from './filters.js'
|
|
15
16
|
import { parseBody } from './markdown.js'
|
|
@@ -274,6 +275,48 @@ export function deleteTask({ store, id }) {
|
|
|
274
275
|
* позвать `lib/markdown.js` не может. Заодно тело не едет в разметку строкой,
|
|
275
276
|
* и подставить туда чужое нечем.
|
|
276
277
|
*/
|
|
278
|
+
/**
|
|
279
|
+
* Метрики доски: где стоит работа и что залежалось.
|
|
280
|
+
*
|
|
281
|
+
* Считается по журналу переходов и ничего не записывает. Порог «залежалось»
|
|
282
|
+
* приходит запросом, а не настройкой: это вопрос, который задают по-разному в
|
|
283
|
+
* разные дни, а не свойство доски.
|
|
284
|
+
*
|
|
285
|
+
* @returns {{columns: object[], done: object, stale: object[], tasks: number}}
|
|
286
|
+
*/
|
|
287
|
+
export function boardMetrics({ store, board = 'main', now = Date.now(), staleDays = 14 }) {
|
|
288
|
+
const tasks = store.listTasks({ board })
|
|
289
|
+
const byTask = new Map()
|
|
290
|
+
for (const row of store.listAllTransitions()) {
|
|
291
|
+
if (!byTask.has(row.taskId)) byTask.set(row.taskId, [])
|
|
292
|
+
byTask.get(row.taskId).push(row)
|
|
293
|
+
}
|
|
294
|
+
const rows = tasks.map((task) => ({ task, moves: byTask.get(task.id) ?? [] }))
|
|
295
|
+
const day = 24 * 60 * 60 * 1000
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
tasks: tasks.length,
|
|
299
|
+
columns: columnStats(rows, now),
|
|
300
|
+
done: {
|
|
301
|
+
week: doneWithin(rows, now - 7 * day, now),
|
|
302
|
+
month: doneWithin(rows, now - 30 * day, now),
|
|
303
|
+
},
|
|
304
|
+
staleDays,
|
|
305
|
+
stale: stale(tasks, now, staleDays * day).slice(0, 50),
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Можно ли предлагать «продолжить работу».
|
|
311
|
+
*
|
|
312
|
+
* Только когда сессия у задачи записана, а живого агента за ней нет: у живой
|
|
313
|
+
* продолжать нечего, у задачи без сессии — нечего возобновлять.
|
|
314
|
+
*/
|
|
315
|
+
export function canRevive(task, live) {
|
|
316
|
+
const has = typeof task?.sessionId === 'string' && task.sessionId !== ''
|
|
317
|
+
return has && (live === undefined || live === null)
|
|
318
|
+
}
|
|
319
|
+
|
|
277
320
|
export function taskBody({ store, id }) {
|
|
278
321
|
const task = store.getTask(id)
|
|
279
322
|
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
package/lib/store.js
CHANGED
|
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
51
51
|
labelColors TEXT NOT NULL DEFAULT '{}',
|
|
52
52
|
author TEXT NOT NULL DEFAULT '',
|
|
53
53
|
assignee TEXT NOT NULL DEFAULT '',
|
|
54
|
+
milestone TEXT NOT NULL DEFAULT '',
|
|
54
55
|
priority TEXT NOT NULL DEFAULT '',
|
|
55
56
|
dueAt INTEGER NOT NULL DEFAULT 0,
|
|
56
57
|
syncedAt INTEGER NOT NULL DEFAULT 0,
|
|
@@ -82,7 +83,7 @@ CREATE TABLE IF NOT EXISTS dismissed (
|
|
|
82
83
|
const WRITABLE = [
|
|
83
84
|
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
|
|
84
85
|
'branch', 'worktree', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
85
|
-
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'priority', 'dueAt',
|
|
86
|
+
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
|
|
86
87
|
]
|
|
87
88
|
|
|
88
89
|
/** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
|
|
@@ -128,6 +129,7 @@ export function openStore({ dir }) {
|
|
|
128
129
|
['labelColors', "TEXT NOT NULL DEFAULT '{}'"],
|
|
129
130
|
['author', "TEXT NOT NULL DEFAULT ''"],
|
|
130
131
|
['assignee', "TEXT NOT NULL DEFAULT ''"],
|
|
132
|
+
['milestone', "TEXT NOT NULL DEFAULT ''"],
|
|
131
133
|
['priority', "TEXT NOT NULL DEFAULT ''"],
|
|
132
134
|
['dueAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
133
135
|
]) {
|
|
@@ -259,8 +261,8 @@ export function openStore({ dir }) {
|
|
|
259
261
|
db.prepare(`INSERT INTO tasks
|
|
260
262
|
(id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
|
|
261
263
|
labels, branch, worktree, sessionId, model, provider, waiting, syncedAt,
|
|
262
|
-
labelColors, author, assignee, priority, dueAt, columnAt, createdAt, updatedAt)
|
|
263
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
264
|
+
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt)
|
|
265
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
264
266
|
id, board, column, position,
|
|
265
267
|
input.title ?? '', input.body ?? '',
|
|
266
268
|
input.owner ?? null, input.repo ?? null,
|
|
@@ -273,6 +275,7 @@ export function openStore({ dir }) {
|
|
|
273
275
|
JSON.stringify(input.labelColors ?? {}),
|
|
274
276
|
input.author ?? '',
|
|
275
277
|
input.assignee ?? '',
|
|
278
|
+
input.milestone ?? '',
|
|
276
279
|
input.priority ?? '',
|
|
277
280
|
Number(input.dueAt) || 0,
|
|
278
281
|
now, born, now,
|
|
@@ -372,6 +375,11 @@ export function openStore({ dir }) {
|
|
|
372
375
|
return q.transitionsOf.all(taskId)
|
|
373
376
|
},
|
|
374
377
|
|
|
378
|
+
/** Все переходы разом: метрике нужен журнал целиком, а не по одной задаче. */
|
|
379
|
+
listAllTransitions() {
|
|
380
|
+
return q.allTransitions.all()
|
|
381
|
+
},
|
|
382
|
+
|
|
375
383
|
/**
|
|
376
384
|
* Полный снимок доски для экспорта: все доски, все задачи (включая
|
|
377
385
|
* архивные) и журнал переходов. Импорт восстанавливает его как есть.
|
package/lib/sync.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { deriveColumn, resolveTransition, branchOfTask, pullsOfTask, resolveConflict } from './transitions.js'
|
|
8
8
|
import { watchedRepos, shouldTake, archiveBefore, isWatched } from './intake.js'
|
|
9
9
|
import { colorsOfIssue } from './filters.js'
|
|
10
|
-
import { issueToTask, issueBornAt, issueAuthor, issueAssignee } from './import.js'
|
|
10
|
+
import { issueToTask, issueBornAt, issueAuthor, issueAssignee, issueMilestone } from './import.js'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Наблюдения по одному репозиторию.
|
|
@@ -100,6 +100,10 @@ function remoteFields(task, issue) {
|
|
|
100
100
|
const assignee = issueAssignee(issue)
|
|
101
101
|
if (assignee !== (task.assignee ?? '')) patch.assignee = assignee
|
|
102
102
|
}
|
|
103
|
+
if ('milestone' in (issue ?? {})) {
|
|
104
|
+
const milestone = issueMilestone(issue)
|
|
105
|
+
if (milestone !== (task.milestone ?? '')) patch.milestone = milestone
|
|
106
|
+
}
|
|
103
107
|
return patch
|
|
104
108
|
}
|
|
105
109
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
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",
|