@goodandready/dsh-kanban 0.1.21 → 0.1.22

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,24 @@ npm test
207
216
 
208
217
  ---
209
218
 
219
+ ## 🔌 Core Compatibility Check
220
+
221
+ A named import of an export the installed core no longer has is a `SyntaxError`
222
+ at parse time: the whole plugin tree fails to load and the harness restarts in a
223
+ loop. That happened twice in one week, and both times the owner found out before
224
+ the tests did.
225
+
226
+ ```bash
227
+ npm run compat # uses $DSH_HOME, or ~/.dsh/profiles/web
228
+ npm run compat -- /path/to/profile
229
+ ```
230
+
231
+ The check reads every `import { … } from '@deepseek-ai/…'` in `lib/`, resolves
232
+ each package the way the harness resolves it — from the profile directory — and
233
+ compares the requested names against the actual exports. It is deliberately not
234
+ part of `npm test`: the test suite runs without a core and without a profile.
235
+ Run it on the machine where the harness lives, before publishing.
236
+
210
237
  ## 📄 License
211
238
 
212
239
  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,
@@ -948,6 +969,11 @@ window.__ModuleLoader__.load({
948
969
  'facet.repo': 'Repository',
949
970
  'facet.author': 'Author',
950
971
  'facet.assignee': 'Assignee',
972
+ 'board.mine': 'Mine: {n}',
973
+ 'board.mineHint': 'Tasks assigned to {who}',
974
+ 'group.repo': 'By project',
975
+ 'group.assignee': 'By assignee',
976
+ 'group.nobody': 'nobody took it',
951
977
  'card.assigneeHint': 'Who took the task',
952
978
  'panel.assignee': 'taken by {who}',
953
979
  'panel.assignMe': 'Take it',
@@ -1199,6 +1225,11 @@ window.__ModuleLoader__.load({
1199
1225
  'facet.repo': '仓库',
1200
1226
  'facet.author': '创建者',
1201
1227
  'facet.assignee': '负责人',
1228
+ 'board.mine': '我的:{n}',
1229
+ 'board.mineHint': '指派给 {who} 的任务',
1230
+ 'group.repo': '按项目',
1231
+ 'group.assignee': '按负责人',
1232
+ 'group.nobody': '无人接手',
1202
1233
  'card.assigneeHint': '谁接下了这个任务',
1203
1234
  'panel.assignee': '由 {who} 接手',
1204
1235
  'panel.assignMe': '我来接',
@@ -1741,6 +1772,13 @@ window.__ModuleLoader__.load({
1741
1772
  const [onlyOverdue, setOnlyOverdue] = React.useState(false)
1742
1773
  const [hoverSession, setHoverSession] = React.useState('')
1743
1774
  const [order, setOrder] = React.useState('manual')
1775
+ // «По проектам» — как приходит с сервера; «по ответственному» — своя
1776
+ // раскладка поверх того же порядка.
1777
+ const [grouping, setGrouping] = React.useState('repo')
1778
+ // Кто «я»: спрашивается один раз у сервера. Пусто — Gitea не ответил, и
1779
+ // кнопку «Мои» рисовать нельзя: соврать числом хуже, чем не показать.
1780
+ const [me, setMe] = React.useState('')
1781
+ const [onlyMine, setOnlyMine] = React.useState(false)
1744
1782
  // Чем запускать: профиль агента и уровень доступа. Обе службы у ядра
1745
1783
  // необязательны, поэтому пустой список — не поломка, а «выбирать не из
1746
1784
  // чего»: поле тогда не рисуется вовсе.
@@ -1830,6 +1868,9 @@ window.__ModuleLoader__.load({
1830
1868
 
1831
1869
  React.useEffect(() => {
1832
1870
  let alive = true
1871
+ api('/whoami')
1872
+ .then((out) => { if (alive && out) setMe(typeof out.login === 'string' ? out.login : '') })
1873
+ .catch(() => {})
1833
1874
  api('/presets')
1834
1875
  .then((out) => {
1835
1876
  if (!alive || !out) return
@@ -2011,6 +2052,26 @@ window.__ModuleLoader__.load({
2011
2052
  onClick: () => setOnlyOverdue((cur) => !cur),
2012
2053
  }, t('board.overdueCount', { n: overdue }))
2013
2054
  })(),
2055
+ (() => {
2056
+ // Ноль своих задач — кнопки нет: пустой счётчик в шапке перестают
2057
+ // замечать вместе с непустым.
2058
+ if (me === '') return null
2059
+ const mine = state.tasks.filter((x) => x.assignee === me).length
2060
+ if (mine === 0) return null
2061
+ return React.createElement('button', {
2062
+ type: 'button',
2063
+ className: 'dkb-waitCount' + (onlyMine ? ' dkb-save' : ''),
2064
+ title: t('board.mineHint', { who: me }),
2065
+ onClick: () => setOnlyMine((cur) => !cur),
2066
+ }, t('board.mine', { n: mine }))
2067
+ })(),
2068
+ React.createElement('select', {
2069
+ className: 'dkb-input', style: { width: 'auto' }, value: grouping,
2070
+ onChange: (e) => setGrouping(e.target.value),
2071
+ },
2072
+ React.createElement('option', { value: 'repo' }, t('group.repo')),
2073
+ React.createElement('option', { value: 'assignee' }, t('group.assignee')),
2074
+ ),
2014
2075
  React.createElement('select', {
2015
2076
  className: 'dkb-input', style: { width: 'auto' }, value: order,
2016
2077
  onChange: (e) => setOrder(e.target.value),
@@ -2204,9 +2265,18 @@ window.__ModuleLoader__.load({
2204
2265
  const items = sortTasks(
2205
2266
  all.filter((task) => matchesQuery(task, query))
2206
2267
  .filter((task) => !onlyWaiting || task.state === 'waiting')
2207
- .filter((task) => !onlyOverdue || task.overdue === true),
2268
+ .filter((task) => !onlyOverdue || task.overdue === true)
2269
+ .filter((task) => !onlyMine || task.assignee === me),
2208
2270
  order,
2209
2271
  )
2272
+ // Ключ группы решает переключатель: заголовки, сворачивание и
2273
+ // счётчики остаются те же, меняется только по чему делим.
2274
+ const groupKeyOf = (task) => (grouping === 'assignee'
2275
+ ? (task.assignee || '')
2276
+ : (task.repo || ''))
2277
+ const ordered = grouping === 'assignee'
2278
+ ? groupByAssignee(items).flatMap((one) => one.tasks)
2279
+ : items
2210
2280
  const limit = col.limit
2211
2281
  const overLimit = limit !== undefined && limit !== null && all.length > limit
2212
2282
  const slot = (index) => React.createElement('div', {
@@ -2226,11 +2296,11 @@ window.__ModuleLoader__.load({
2226
2296
  // разложенными, поэтому группы идут подряд и разрываться не могут.
2227
2297
  let group = null
2228
2298
  const sizes = new Map()
2229
- for (const x of items) sizes.set(x.repo || '', (sizes.get(x.repo || '') || 0) + 1)
2299
+ for (const x of ordered) sizes.set(groupKeyOf(x), (sizes.get(groupKeyOf(x)) || 0) + 1)
2230
2300
  const manyGroups = sizes.size > 1
2231
2301
 
2232
- items.forEach((task, i) => {
2233
- const repo = task.repo || ''
2302
+ ordered.forEach((task, i) => {
2303
+ const repo = groupKeyOf(task)
2234
2304
  if (manyGroups && repo !== group) {
2235
2305
  group = repo
2236
2306
  const shut = groups[col.id + '/' + repo] === true
@@ -2239,7 +2309,8 @@ window.__ModuleLoader__.load({
2239
2309
  'aria-expanded': !shut,
2240
2310
  onClick: () => setGroups((cur) => Object.assign({}, cur, { [col.id + '/' + repo]: !shut })),
2241
2311
  },
2242
- React.createElement('span', null, repo || t('board.noRepo')),
2312
+ React.createElement('span', null,
2313
+ repo || t(grouping === 'assignee' ? 'group.nobody' : 'board.noRepo')),
2243
2314
  // Счётчик виден и у свёрнутой: группа с работой, выглядящая
2244
2315
  // пустой, — то же зло, что и свёрнутая колонка без числа.
2245
2316
  React.createElement('span', { className: 'dkb-groupCount' }, String(sizes.get(repo))),
package/lib/index.js CHANGED
@@ -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,33 @@ 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/whoami',
551
+ // Пустой логин — законный ответ: Gitea может быть не настроен или не
552
+ // отвечать. Браузер по нему просто не рисует «Мои», а не показывает ноль.
553
+ handler: async (req, res) => { reply(res, { login: await whoAmI() }) },
554
+ }), 'dsh-kanban: кто мы для Gitea')
555
+
528
556
  ctx.effect(() => ctx.webServer.register({
529
557
  kind: 'exact',
530
558
  path: '/dsh-kanban/snapshot',
@@ -619,6 +647,7 @@ export function apply(ctx, config) {
619
647
  agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
620
648
  permission: typeof input.permission === 'string' ? input.permission : undefined,
621
649
  permissions: ctx.get('permissionPresets'),
650
+ claimAs: await whoAmI(),
622
651
  }))
623
652
  } catch (error) {
624
653
  ctx.logger?.warn?.(`dsh-kanban: запуск задачи не удался: ${error?.message}`)
@@ -681,12 +710,8 @@ export function apply(ctx, config) {
681
710
  // токеном ходит доска: своего понятия пользователя у харнесса нет.
682
711
  let login = typeof input.login === 'string' ? input.login.trim() : ''
683
712
  if (login === 'me') {
684
- try {
685
- login = await gitea.me()
686
- } catch (error) {
687
- writeJson(res, 502, { error: 'gitea-unreachable' })
688
- return
689
- }
713
+ login = await whoAmI()
714
+ if (login === '') { writeJson(res, 502, { error: 'gitea-unreachable' }); return }
690
715
  }
691
716
  const out = assignTask({ store, id, login })
692
717
  if (out.task !== undefined && config.pushToGitea !== false) {
package/lib/launcher.js CHANGED
@@ -199,10 +199,34 @@ 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 {string} логин, если назначение случилось; иначе пустая строка
210
+ */
211
+ export function claimOnStart({ store, task, login }) {
212
+ const who = typeof login === 'string' ? login.trim() : ''
213
+ if (who === '') return ''
214
+ if (typeof task?.assignee === 'string' && task.assignee !== '') return ''
215
+ store.updateTask(task.id, { assignee: who })
216
+ store.addTransition({
217
+ taskId: task.id,
218
+ fromCol: task.column,
219
+ toCol: task.column,
220
+ source: 'manual',
221
+ detail: `ответственный: ${who} (взял при запуске)`,
222
+ })
223
+ return who
224
+ }
225
+
202
226
  export async function runTask({
203
227
  agents, store, task, config, provider, model,
204
228
  mintSessionId, createMessage, cwdOf, logger, text,
205
- agentPreset, permission, permissions,
229
+ agentPreset, permission, permissions, claimAs,
206
230
  }) {
207
231
  const taken = await obtainAgent({
208
232
  agents, task, config, provider, model, mintSessionId, cwdOf, logger,
@@ -220,6 +244,9 @@ export async function runTask({
220
244
  const before = store.getTask(task.id)
221
245
  const patch = { sessionId: taken.sessionId, provider, model }
222
246
  store.updateTask(task.id, patch)
247
+ // Запустил — значит отвечаешь: свободная задача становится твоей, чужая
248
+ // остаётся чужой.
249
+ claimOnStart({ store, task: before, login: claimAs })
223
250
 
224
251
  // Подмена чата не должна быть молчаливой: человек вернётся к задаче и не
225
252
  // поймёт, куда делась переписка.
@@ -297,7 +324,7 @@ export function buildQueuedMessage(task, config, at, total) {
297
324
  export async function runBatch({
298
325
  agents, store, tasks, config, provider, model,
299
326
  mintSessionId, createMessage, cwdOf, logger, text, now = Date.now(),
300
- agentPreset, permission, permissions,
327
+ agentPreset, permission, permissions, claimAs,
301
328
  }) {
302
329
  const list = (tasks ?? []).filter((task) => task !== undefined)
303
330
  if (list.length === 0) return { error: 'nothing-picked', status: 400 }
@@ -329,6 +356,9 @@ export async function runBatch({
329
356
  // даже когда всё случилось в одну миллисекунду.
330
357
  const patch = { sessionId: taken.sessionId, provider, model, queuedAt: at === 0 ? 0 : now + at }
331
358
  store.updateTask(task.id, patch)
359
+ // Пачку берёт на себя тот, кто её запустил: пять карточек в работе без
360
+ // ответственного выглядят свободными.
361
+ claimOnStart({ store, task: before, login: claimAs })
332
362
  if (at === 0 && before.column === 'backlog') {
333
363
  store.moveTask(task.id, { column: 'in-progress' })
334
364
  store.addTransition({
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.22",
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",