@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 +27 -0
- package/lib/client.js +77 -6
- package/lib/index.js +31 -6
- package/lib/launcher.js +32 -2
- package/package.json +3 -2
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
|
|
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
|
-
|
|
2233
|
-
const repo = task
|
|
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,
|
|
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
|
-
|
|
685
|
-
|
|
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.
|
|
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",
|