@goodandready/dsh-kanban 0.1.21 → 0.1.23

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 CHANGED
@@ -141,6 +141,15 @@ back to the issue through the outbound queue, so the board keeps working while
141
141
  Gitea is down; dropping sends an empty assignee list, because Gitea reads a
142
142
  missing field as "leave it alone".
143
143
 
144
+ Three shortcuts follow from ownership. The column groups **by assignee** as
145
+ well as by project, with "nobody took it" always first — that group is what the
146
+ layout is opened for. A **"Mine: N"** button in the header narrows the board to
147
+ your own tasks in one click; it is drawn only when Gitea told the board who you
148
+ are and you actually have tasks, because a lying counter is worse than none.
149
+ And **starting work takes the task**: a free task becomes yours on launch, alone
150
+ or as part of a batch, while a task already assigned to someone else is left
151
+ alone.
152
+
144
153
  Assignment made in Gitea arrives on the next sync — in both directions,
145
154
  including removal. Filtering by "nobody" answers the question the board is
146
155
  opened for most often: what is free to pick up.
@@ -207,6 +216,50 @@ npm test
207
216
 
208
217
  ---
209
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
+
245
+ ## 🔌 Core Compatibility Check
246
+
247
+ A named import of an export the installed core no longer has is a `SyntaxError`
248
+ at parse time: the whole plugin tree fails to load and the harness restarts in a
249
+ loop. That happened twice in one week, and both times the owner found out before
250
+ the tests did.
251
+
252
+ ```bash
253
+ npm run compat # uses $DSH_HOME, or ~/.dsh/profiles/web
254
+ npm run compat -- /path/to/profile
255
+ ```
256
+
257
+ The check reads every `import { … } from '@deepseek-ai/…'` in `lib/`, resolves
258
+ each package the way the harness resolves it — from the profile directory — and
259
+ compares the requested names against the actual exports. It is deliberately not
260
+ part of `npm test`: the test suite runs without a core and without a profile.
261
+ Run it on the machine where the harness lives, before publishing.
262
+
210
263
  ## 📄 License
211
264
 
212
265
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/lib/client.js CHANGED
@@ -149,6 +149,27 @@ window.__ModuleLoader__.load({
149
149
  return out
150
150
  }
151
151
 
152
+ /**
153
+ * Разложить колонку по ответственным.
154
+ *
155
+ * Группа «никто» идёт первой: свободные задачи — то, ради чего в эту
156
+ * раскладку и заглядывают. Дальше по алфавиту, чтобы порядок не плясал от
157
+ * того, кто первым попался в списке.
158
+ *
159
+ * @returns {Array<{who: string, tasks: object[]}>}
160
+ */
161
+ function groupByAssignee(tasks) {
162
+ const by = new Map()
163
+ for (const task of tasks || []) {
164
+ const who = typeof task.assignee === 'string' && task.assignee !== '' ? task.assignee : ''
165
+ if (!by.has(who)) by.set(who, [])
166
+ by.get(who).push(task)
167
+ }
168
+ return [...by.entries()]
169
+ .sort((a, b) => (a[0] === '' ? -1 : b[0] === '' ? 1 : a[0].localeCompare(b[0])))
170
+ .map(([who, list]) => ({ who, tasks: list }))
171
+ }
172
+
152
173
  function cardRef(task) {
153
174
  if (!task || !task.repo) return ''
154
175
  if (typeof task.issueNumber !== 'number') return task.repo
@@ -599,7 +620,7 @@ window.__ModuleLoader__.load({
599
620
  }
600
621
 
601
622
  const helpers = {
602
- neighboursFor, tasksOf, sortTasks, packInfo, applyToEach, recallLaunch, rememberLaunch, stillOffered,
623
+ neighboursFor, tasksOf, sortTasks, packInfo, groupByAssignee, applyToEach, recallLaunch, rememberLaunch, stillOffered,
603
624
  cardRef, taskRef, cardStatus, facetLabel, boardTitle,
604
625
  columnTitle, planLine, stateLabel, isCollapsed,
605
626
  agoText, exactAt, shortDate, syncLine, readableOn, tagStyle, priorityColor, renderBlocks, renderSpans, matchesFilters, anySelected, toggleValue, normalizeBoard, errorKey,
@@ -929,6 +950,14 @@ window.__ModuleLoader__.load({
929
950
  'error.repo-not-created': 'The repository was not created; nothing else was done.',
930
951
  'error.issue-not-created': 'The issue was not created. If the repository was new, it stayed — nothing was deleted.',
931
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.',
932
961
  'board.export': 'Download',
933
962
  'board.import': 'Import',
934
963
  'board.exportHint': 'Download the whole board as JSON',
@@ -948,6 +977,11 @@ window.__ModuleLoader__.load({
948
977
  'facet.repo': 'Repository',
949
978
  'facet.author': 'Author',
950
979
  'facet.assignee': 'Assignee',
980
+ 'board.mine': 'Mine: {n}',
981
+ 'board.mineHint': 'Tasks assigned to {who}',
982
+ 'group.repo': 'By project',
983
+ 'group.assignee': 'By assignee',
984
+ 'group.nobody': 'nobody took it',
951
985
  'card.assigneeHint': 'Who took the task',
952
986
  'panel.assignee': 'taken by {who}',
953
987
  'panel.assignMe': 'Take it',
@@ -958,6 +992,9 @@ window.__ModuleLoader__.load({
958
992
  'board.pickMove': 'Move to…',
959
993
  'board.someFailed': 'Done for {done} of {total}; the rest refused.',
960
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.',
961
998
  'card.pack': 'batch {at} of {total}',
962
999
  'card.packHint': 'Started together, one session; the agent takes them in this order.',
963
1000
  'stop.idle': 'The agent was not running — there was nothing to stop.',
@@ -1180,6 +1217,14 @@ window.__ModuleLoader__.load({
1180
1217
  'error.repo-not-created': '仓库没有创建,其他动作也未执行。',
1181
1218
  'error.issue-not-created': 'issue 没有创建。如果仓库是新建的,它仍然保留——没有删除任何东西。',
1182
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': '还没有流转记录,无从统计。',
1183
1228
  'board.export': '下载',
1184
1229
  'board.import': '导入',
1185
1230
  'board.exportHint': '将整个看板下载为 JSON',
@@ -1199,6 +1244,11 @@ window.__ModuleLoader__.load({
1199
1244
  'facet.repo': '仓库',
1200
1245
  'facet.author': '创建者',
1201
1246
  'facet.assignee': '负责人',
1247
+ 'board.mine': '我的:{n}',
1248
+ 'board.mineHint': '指派给 {who} 的任务',
1249
+ 'group.repo': '按项目',
1250
+ 'group.assignee': '按负责人',
1251
+ 'group.nobody': '无人接手',
1202
1252
  'card.assigneeHint': '谁接下了这个任务',
1203
1253
  'panel.assignee': '由 {who} 接手',
1204
1254
  'panel.assignMe': '我来接',
@@ -1209,6 +1259,9 @@ window.__ModuleLoader__.load({
1209
1259
  'board.pickMove': '移动到…',
1210
1260
  'board.someFailed': '{total} 个中完成了 {done} 个,其余被拒绝。',
1211
1261
  'card.stop': '停止',
1262
+ 'card.revive': '继续工作',
1263
+ 'revive.same': '原会话已恢复,对话还在原处。',
1264
+ 'revive.fresh': '原会话已不在,因此新开了一个;先前的对话不在其中。',
1212
1265
  'card.pack': '批次 {at}/{total}',
1213
1266
  'card.packHint': '一起启动,同一个会话;Agent 按这个顺序接手。',
1214
1267
  'stop.idle': 'Agent 本来就没有在跑,没有什么可停。',
@@ -1589,6 +1642,12 @@ window.__ModuleLoader__.load({
1589
1642
  // работу», разница лишь в том, кому и когда.
1590
1643
  // Останавливать нечего у того, кто не идёт: пункт, который
1591
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,
1592
1651
  task.state === 'running' || task.state === 'waiting'
1593
1652
  ? React.createElement('button', {
1594
1653
  type: 'button', className: 'dkb-facetRow', key: 'stop',
@@ -1735,12 +1794,20 @@ window.__ModuleLoader__.load({
1735
1794
  // выбор переживает и открытие окна, и обновление доски.
1736
1795
  const [picked, setPicked] = React.useState({})
1737
1796
  const [archive, setArchive] = React.useState(null)
1797
+ const [metrics, setMetrics] = React.useState(null)
1738
1798
  // «Только ждущие» держим отдельно от отборов: это не значение измерения,
1739
1799
  // а взгляд «покажи, где стоит работа», и складывается он с любым отбором.
1740
1800
  const [onlyWaiting, setOnlyWaiting] = React.useState(false)
1741
1801
  const [onlyOverdue, setOnlyOverdue] = React.useState(false)
1742
1802
  const [hoverSession, setHoverSession] = React.useState('')
1743
1803
  const [order, setOrder] = React.useState('manual')
1804
+ // «По проектам» — как приходит с сервера; «по ответственному» — своя
1805
+ // раскладка поверх того же порядка.
1806
+ const [grouping, setGrouping] = React.useState('repo')
1807
+ // Кто «я»: спрашивается один раз у сервера. Пусто — Gitea не ответил, и
1808
+ // кнопку «Мои» рисовать нельзя: соврать числом хуже, чем не показать.
1809
+ const [me, setMe] = React.useState('')
1810
+ const [onlyMine, setOnlyMine] = React.useState(false)
1744
1811
  // Чем запускать: профиль агента и уровень доступа. Обе службы у ядра
1745
1812
  // необязательны, поэтому пустой список — не поломка, а «выбирать не из
1746
1813
  // чего»: поле тогда не рисуется вовсе.
@@ -1830,6 +1897,9 @@ window.__ModuleLoader__.load({
1830
1897
 
1831
1898
  React.useEffect(() => {
1832
1899
  let alive = true
1900
+ api('/whoami')
1901
+ .then((out) => { if (alive && out) setMe(typeof out.login === 'string' ? out.login : '') })
1902
+ .catch(() => {})
1833
1903
  api('/presets')
1834
1904
  .then((out) => {
1835
1905
  if (!alive || !out) return
@@ -2011,6 +2081,26 @@ window.__ModuleLoader__.load({
2011
2081
  onClick: () => setOnlyOverdue((cur) => !cur),
2012
2082
  }, t('board.overdueCount', { n: overdue }))
2013
2083
  })(),
2084
+ (() => {
2085
+ // Ноль своих задач — кнопки нет: пустой счётчик в шапке перестают
2086
+ // замечать вместе с непустым.
2087
+ if (me === '') return null
2088
+ const mine = state.tasks.filter((x) => x.assignee === me).length
2089
+ if (mine === 0) return null
2090
+ return React.createElement('button', {
2091
+ type: 'button',
2092
+ className: 'dkb-waitCount' + (onlyMine ? ' dkb-save' : ''),
2093
+ title: t('board.mineHint', { who: me }),
2094
+ onClick: () => setOnlyMine((cur) => !cur),
2095
+ }, t('board.mine', { n: mine }))
2096
+ })(),
2097
+ React.createElement('select', {
2098
+ className: 'dkb-input', style: { width: 'auto' }, value: grouping,
2099
+ onChange: (e) => setGrouping(e.target.value),
2100
+ },
2101
+ React.createElement('option', { value: 'repo' }, t('group.repo')),
2102
+ React.createElement('option', { value: 'assignee' }, t('group.assignee')),
2103
+ ),
2014
2104
  React.createElement('select', {
2015
2105
  className: 'dkb-input', style: { width: 'auto' }, value: order,
2016
2106
  onChange: (e) => setOrder(e.target.value),
@@ -2019,11 +2109,22 @@ window.__ModuleLoader__.load({
2019
2109
  React.createElement('option', { value: 'old' }, t('order.old')),
2020
2110
  React.createElement('option', { value: 'new' }, t('order.new')),
2021
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')),
2022
2122
  React.createElement('button', {
2023
2123
  type: 'button', className: archive ? 'dkb-save' : 'dkb-discard',
2024
2124
  onClick: async () => {
2025
2125
  if (archive) { setArchive(null); return }
2026
2126
  try {
2127
+ setMetrics(null)
2027
2128
  const out = await api('/archive')
2028
2129
  setArchive(Array.isArray(out.tasks) ? out.tasks : [])
2029
2130
  } catch (e) { setError(t(e.key || 'error.unknown')) }
@@ -2204,9 +2305,18 @@ window.__ModuleLoader__.load({
2204
2305
  const items = sortTasks(
2205
2306
  all.filter((task) => matchesQuery(task, query))
2206
2307
  .filter((task) => !onlyWaiting || task.state === 'waiting')
2207
- .filter((task) => !onlyOverdue || task.overdue === true),
2308
+ .filter((task) => !onlyOverdue || task.overdue === true)
2309
+ .filter((task) => !onlyMine || task.assignee === me),
2208
2310
  order,
2209
2311
  )
2312
+ // Ключ группы решает переключатель: заголовки, сворачивание и
2313
+ // счётчики остаются те же, меняется только по чему делим.
2314
+ const groupKeyOf = (task) => (grouping === 'assignee'
2315
+ ? (task.assignee || '')
2316
+ : (task.repo || ''))
2317
+ const ordered = grouping === 'assignee'
2318
+ ? groupByAssignee(items).flatMap((one) => one.tasks)
2319
+ : items
2210
2320
  const limit = col.limit
2211
2321
  const overLimit = limit !== undefined && limit !== null && all.length > limit
2212
2322
  const slot = (index) => React.createElement('div', {
@@ -2226,11 +2336,11 @@ window.__ModuleLoader__.load({
2226
2336
  // разложенными, поэтому группы идут подряд и разрываться не могут.
2227
2337
  let group = null
2228
2338
  const sizes = new Map()
2229
- for (const x of items) sizes.set(x.repo || '', (sizes.get(x.repo || '') || 0) + 1)
2339
+ for (const x of ordered) sizes.set(groupKeyOf(x), (sizes.get(groupKeyOf(x)) || 0) + 1)
2230
2340
  const manyGroups = sizes.size > 1
2231
2341
 
2232
- items.forEach((task, i) => {
2233
- const repo = task.repo || ''
2342
+ ordered.forEach((task, i) => {
2343
+ const repo = groupKeyOf(task)
2234
2344
  if (manyGroups && repo !== group) {
2235
2345
  group = repo
2236
2346
  const shut = groups[col.id + '/' + repo] === true
@@ -2239,7 +2349,8 @@ window.__ModuleLoader__.load({
2239
2349
  'aria-expanded': !shut,
2240
2350
  onClick: () => setGroups((cur) => Object.assign({}, cur, { [col.id + '/' + repo]: !shut })),
2241
2351
  },
2242
- React.createElement('span', null, repo || t('board.noRepo')),
2352
+ React.createElement('span', null,
2353
+ repo || t(grouping === 'assignee' ? 'group.nobody' : 'board.noRepo')),
2243
2354
  // Счётчик виден и у свёрнутой: группа с работой, выглядящая
2244
2355
  // пустой, — то же зло, что и свёрнутая колонка без числа.
2245
2356
  React.createElement('span', { className: 'dkb-groupCount' }, String(sizes.get(repo))),
@@ -2261,6 +2372,18 @@ window.__ModuleLoader__.load({
2261
2372
  pack: packs[task.id],
2262
2373
  sameSession: hoverSession !== '' && task.sessionId === hoverSession,
2263
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
+ },
2264
2387
  onStop: async (x) => {
2265
2388
  try {
2266
2389
  const out = await api('/task/' + encodeURIComponent(x.id) + '/stop', { method: 'POST', body: '{}' })
@@ -3002,10 +3125,46 @@ window.__ModuleLoader__.load({
3002
3125
  )
3003
3126
  : null
3004
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
+
3005
3164
  return React.createElement('div', { className: 'dkb-screen' },
3006
3165
  style, bar,
3007
3166
  error ? React.createElement('p', { className: 'dkb-failed' }, error) : null,
3008
- archive ? archiveEl : columns,
3167
+ metrics ? metricsEl : archive ? archiveEl : columns,
3009
3168
  panel, dialogEl, moveDialogEl, batchEl, queueEl,
3010
3169
  )
3011
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 {
@@ -387,6 +387,7 @@ export function apply(ctx, config) {
387
387
  agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
388
388
  permission: typeof input.permission === 'string' ? input.permission : undefined,
389
389
  permissions: ctx.get('permissionPresets'),
390
+ claimAs: await whoAmI(),
390
391
  }))
391
392
  } catch (error) {
392
393
  writeJson(res, error?.status ?? 500, { error: 'batch-failed' })
@@ -525,6 +526,47 @@ export function apply(ctx, config) {
525
526
  // самый путь, на котором с первого дня живёт импорт issue из Gitea. Два
526
527
  // обработчика на одном пути означают, что один из них не отвечает вовсе, и
527
528
  // какой именно — решает порядок регистрации, а не мы.
529
+ /**
530
+ * Кто мы для Gitea, с памятью на время работы.
531
+ *
532
+ * Логин нужен и «взять себе», и запуску работы. Спрашивать его у Gitea
533
+ * каждый раз значит добавить в путь запуска ещё одну сеть, которая может не
534
+ * ответить; поэтому спрашиваем один раз и помним. Не ответила — работаем без
535
+ * назначения, а не отказываем в запуске.
536
+ */
537
+ let knownLogin
538
+ const whoAmI = async () => {
539
+ if (knownLogin !== undefined) return knownLogin
540
+ try {
541
+ knownLogin = await gitea.me()
542
+ } catch (error) {
543
+ knownLogin = ''
544
+ }
545
+ return knownLogin
546
+ }
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
+
562
+ ctx.effect(() => ctx.webServer.register({
563
+ kind: 'exact',
564
+ path: '/dsh-kanban/whoami',
565
+ // Пустой логин — законный ответ: Gitea может быть не настроен или не
566
+ // отвечать. Браузер по нему просто не рисует «Мои», а не показывает ноль.
567
+ handler: async (req, res) => { reply(res, { login: await whoAmI() }) },
568
+ }), 'dsh-kanban: кто мы для Gitea')
569
+
528
570
  ctx.effect(() => ctx.webServer.register({
529
571
  kind: 'exact',
530
572
  path: '/dsh-kanban/snapshot',
@@ -619,6 +661,7 @@ export function apply(ctx, config) {
619
661
  agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
620
662
  permission: typeof input.permission === 'string' ? input.permission : undefined,
621
663
  permissions: ctx.get('permissionPresets'),
664
+ claimAs: await whoAmI(),
622
665
  }))
623
666
  } catch (error) {
624
667
  ctx.logger?.warn?.(`dsh-kanban: запуск задачи не удался: ${error?.message}`)
@@ -681,12 +724,8 @@ export function apply(ctx, config) {
681
724
  // токеном ходит доска: своего понятия пользователя у харнесса нет.
682
725
  let login = typeof input.login === 'string' ? input.login.trim() : ''
683
726
  if (login === 'me') {
684
- try {
685
- login = await gitea.me()
686
- } catch (error) {
687
- writeJson(res, 502, { error: 'gitea-unreachable' })
688
- return
689
- }
727
+ login = await whoAmI()
728
+ if (login === '') { writeJson(res, 502, { error: 'gitea-unreachable' }); return }
690
729
  }
691
730
  const out = assignTask({ store, id, login })
692
731
  if (out.task !== undefined && config.pushToGitea !== false) {
package/lib/launcher.js CHANGED
@@ -199,10 +199,47 @@ export async function obtainAgent({
199
199
  * @param {string} [text] сообщение человека; пусто — встроенная заготовка
200
200
  * @returns {{sessionId: string, mode: string, task: object}}
201
201
  */
202
+ /**
203
+ * Что случилось при попытке продолжить работу.
204
+ *
205
+ * «Продолжил» и «начал заново» — разные вещи, и человек обязан видеть, какая
206
+ * из них произошла: в первом случае переписка на месте, во втором её больше
207
+ * нет.
208
+ *
209
+ * @returns {'opened'|'resumed'|'created'} как в obtainAgent
210
+ */
211
+ export function revivalKind(mode) {
212
+ return mode === 'opened' || mode === 'resumed' ? mode : 'created'
213
+ }
214
+
215
+ /**
216
+ * Взять задачу на себя при запуске.
217
+ *
218
+ * Запустить работу и оставить задачу ничьей — значит показать её свободной,
219
+ * когда над ней уже работают. Чужое назначение не трогаем: задачу могли отдать
220
+ * другому осознанно.
221
+ *
222
+ * @returns {string} логин, если назначение случилось; иначе пустая строка
223
+ */
224
+ export function claimOnStart({ store, task, login }) {
225
+ const who = typeof login === 'string' ? login.trim() : ''
226
+ if (who === '') return ''
227
+ if (typeof task?.assignee === 'string' && task.assignee !== '') return ''
228
+ store.updateTask(task.id, { assignee: who })
229
+ store.addTransition({
230
+ taskId: task.id,
231
+ fromCol: task.column,
232
+ toCol: task.column,
233
+ source: 'manual',
234
+ detail: `ответственный: ${who} (взял при запуске)`,
235
+ })
236
+ return who
237
+ }
238
+
202
239
  export async function runTask({
203
240
  agents, store, task, config, provider, model,
204
241
  mintSessionId, createMessage, cwdOf, logger, text,
205
- agentPreset, permission, permissions,
242
+ agentPreset, permission, permissions, claimAs,
206
243
  }) {
207
244
  const taken = await obtainAgent({
208
245
  agents, task, config, provider, model, mintSessionId, cwdOf, logger,
@@ -220,6 +257,9 @@ export async function runTask({
220
257
  const before = store.getTask(task.id)
221
258
  const patch = { sessionId: taken.sessionId, provider, model }
222
259
  store.updateTask(task.id, patch)
260
+ // Запустил — значит отвечаешь: свободная задача становится твоей, чужая
261
+ // остаётся чужой.
262
+ claimOnStart({ store, task: before, login: claimAs })
223
263
 
224
264
  // Подмена чата не должна быть молчаливой: человек вернётся к задаче и не
225
265
  // поймёт, куда делась переписка.
@@ -297,7 +337,7 @@ export function buildQueuedMessage(task, config, at, total) {
297
337
  export async function runBatch({
298
338
  agents, store, tasks, config, provider, model,
299
339
  mintSessionId, createMessage, cwdOf, logger, text, now = Date.now(),
300
- agentPreset, permission, permissions,
340
+ agentPreset, permission, permissions, claimAs,
301
341
  }) {
302
342
  const list = (tasks ?? []).filter((task) => task !== undefined)
303
343
  if (list.length === 0) return { error: 'nothing-picked', status: 400 }
@@ -329,6 +369,9 @@ export async function runBatch({
329
369
  // даже когда всё случилось в одну миллисекунду.
330
370
  const patch = { sessionId: taken.sessionId, provider, model, queuedAt: at === 0 ? 0 : now + at }
331
371
  store.updateTask(task.id, patch)
372
+ // Пачку берёт на себя тот, кто её запустил: пять карточек в работе без
373
+ // ответственного выглядят свободными.
374
+ claimOnStart({ store, task: before, login: claimAs })
332
375
  if (at === 0 && before.column === 'backlog') {
333
376
  store.moveTask(task.id, { column: 'in-progress' })
334
377
  store.addTransition({
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.21",
3
+ "version": "0.1.23",
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",
@@ -18,7 +18,8 @@
18
18
  "LICENSE"
19
19
  ],
20
20
  "scripts": {
21
- "test": "node --test test/*.test.mjs"
21
+ "test": "node --test test/*.test.mjs",
22
+ "compat": "node tools/compat.mjs"
22
23
  },
23
24
  "keywords": [
24
25
  "dsh",