@alpacachen/dsh-kanban 1.0.1
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/LICENSE +21 -0
- package/README.md +144 -0
- package/README.zh.md +144 -0
- package/cordis.patch.yml +6 -0
- package/image.png +0 -0
- package/index.js +621 -0
- package/lib/client.js +2092 -0
- package/package.json +89 -0
package/index.js
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-kanban — DSH bundle 宿主插件(标准 Cordis 函数插件)
|
|
3
|
+
*
|
|
4
|
+
* 挂载方式:package.json 的 `dsh.bundle.patch` 指向 cordis.patch.yml,
|
|
5
|
+
* 该补丁层把本插件行插入 profile 组合,Loader 按包名 dsh-kanban 解析本文件。
|
|
6
|
+
*
|
|
7
|
+
* 职责:
|
|
8
|
+
* - 按工作区(项目)隔离:boards 以 workspaceId 为键,每个工作区一块独立看板
|
|
9
|
+
* - 磁盘持久化:经 ctx.fs 写入 <workspaceRoot>/kanban-board-<workspaceId>.json
|
|
10
|
+
* - 模型工具:经 ctx.tools.register 注册 8 个 kanban_* 工具
|
|
11
|
+
* - 浏览器数据层:经 ctx.get('webServer') 注册 /api/kanban 前缀路由
|
|
12
|
+
*
|
|
13
|
+
* 数据模型(每工作区):
|
|
14
|
+
* columns: [{ id, title }]
|
|
15
|
+
* labels: [{ name, color }] —— 标签与颜色绑定,name 为唯一键
|
|
16
|
+
* cards: [{ id, columnId, title, note, label, priority }]
|
|
17
|
+
*/
|
|
18
|
+
export const name = 'dsh-kanban'
|
|
19
|
+
|
|
20
|
+
export const inject = ['tools']
|
|
21
|
+
|
|
22
|
+
export function apply(ctx) {
|
|
23
|
+
const getFs = () => ctx.get('fs')
|
|
24
|
+
const getPolicy = () => ctx.get('sandboxPolicy')
|
|
25
|
+
const getWorkspaceRegistry = () => ctx.get('workspaceRegistry')
|
|
26
|
+
|
|
27
|
+
const boards = new Map() // workspaceId -> { columns, labels, cards }
|
|
28
|
+
const fileTargets = new Map() // workspaceId -> FsTarget | null
|
|
29
|
+
let seq = 0 // 全局自增,用于生成 cN(列)/ kN(卡)唯一 id
|
|
30
|
+
|
|
31
|
+
// ---- id 生成 ----
|
|
32
|
+
const nextId = (prefix) => prefix + (++seq)
|
|
33
|
+
const bumpSeq = (id) => {
|
|
34
|
+
if (typeof id !== 'string') return
|
|
35
|
+
const n = Number(id.slice(1))
|
|
36
|
+
if (Number.isFinite(n) && n > seq) seq = n
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---- 默认看板 ----
|
|
40
|
+
const DEFAULT_COLUMNS = ['Todo', 'In Progress', 'Review', 'Done']
|
|
41
|
+
const DEFAULT_LABELS = [
|
|
42
|
+
{ name: 'New Feature', color: '#38bdf8' },
|
|
43
|
+
{ name: 'bug', color: '#f87171' },
|
|
44
|
+
{ name: 'Feedback', color: '#34d399' },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
// ---- 持久化定位 ----
|
|
48
|
+
const root = () => {
|
|
49
|
+
const p = getPolicy()
|
|
50
|
+
return p && typeof p.workspaceRoot === 'string' ? p.workspaceRoot : undefined
|
|
51
|
+
}
|
|
52
|
+
const fileName = (wsid) => 'kanban-board-' + wsid + '.json'
|
|
53
|
+
const resolveFile = async (wsid) => {
|
|
54
|
+
const fs = getFs()
|
|
55
|
+
if (!fs) return null
|
|
56
|
+
try {
|
|
57
|
+
return await fs.resolve(fileName(wsid), root() ? { cwd: root() } : {})
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.log('dsh-kanban: 解析看板文件失败,退回内存模式:' + ((err && err.message) || err))
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const targetOf = async (wsid) => {
|
|
64
|
+
if (!fileTargets.has(wsid)) fileTargets.set(wsid, await resolveFile(wsid))
|
|
65
|
+
return fileTargets.get(wsid)
|
|
66
|
+
}
|
|
67
|
+
const persistedFlag = (wsid) => fileTargets.has(wsid) && fileTargets.get(wsid) !== null
|
|
68
|
+
|
|
69
|
+
// ---- 看板读写 ----
|
|
70
|
+
const boardOf = async (wsid) => {
|
|
71
|
+
let board = boards.get(wsid)
|
|
72
|
+
if (board) return board
|
|
73
|
+
board = { columns: [], labels: [], cards: [] }
|
|
74
|
+
boards.set(wsid, board)
|
|
75
|
+
const fs = getFs()
|
|
76
|
+
const target = await targetOf(wsid)
|
|
77
|
+
if (fs && target) {
|
|
78
|
+
try {
|
|
79
|
+
const data = JSON.parse(await fs.readText(target))
|
|
80
|
+
if (data && Array.isArray(data.columns) && Array.isArray(data.cards)) {
|
|
81
|
+
board.columns = data.columns
|
|
82
|
+
board.cards = data.cards
|
|
83
|
+
board.labels = Array.isArray(data.labels) ? data.labels : []
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// 尚无看板文件(首次使用),保留默认空板
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const col of board.columns) bumpSeq(col.id)
|
|
90
|
+
for (const card of board.cards) bumpSeq(card.id)
|
|
91
|
+
if (board.columns.length === 0) {
|
|
92
|
+
for (const title of DEFAULT_COLUMNS) board.columns.push({ id: nextId('c'), title })
|
|
93
|
+
}
|
|
94
|
+
if (board.labels.length === 0) {
|
|
95
|
+
board.labels = DEFAULT_LABELS.map((l) => ({ ...l }))
|
|
96
|
+
}
|
|
97
|
+
return board
|
|
98
|
+
}
|
|
99
|
+
const save = async (wsid) => {
|
|
100
|
+
const fs = getFs()
|
|
101
|
+
const target = await targetOf(wsid)
|
|
102
|
+
const board = boards.get(wsid)
|
|
103
|
+
if (!fs || !target || !board) return
|
|
104
|
+
try {
|
|
105
|
+
await fs.writeText(target, JSON.stringify({ columns: board.columns, labels: board.labels, cards: board.cards }))
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.log('dsh-kanban: 保存失败 ' + wsid + ':' + ((err && err.message) || err))
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- 校验 / 查找 / 序列化 ----
|
|
112
|
+
const str = (v, fb) => (typeof v === 'string' ? v : fb)
|
|
113
|
+
const PRIORITIES = ['high', 'medium', 'low']
|
|
114
|
+
const normPriority = (v) => (typeof v === 'string' && PRIORITIES.includes(v) ? v : undefined)
|
|
115
|
+
const normColor = (v) => (typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v) ? v.toLowerCase() : undefined)
|
|
116
|
+
const findCard = (b, id) => b.cards.find((c) => c.id === id)
|
|
117
|
+
const findColumn = (b, id) => b.columns.find((c) => c.id === id)
|
|
118
|
+
const findLabel = (b, name) => b.labels.find((l) => l.name === name)
|
|
119
|
+
|
|
120
|
+
const cloneBoard = (b) => ({
|
|
121
|
+
columns: b.columns.map((c) => ({ id: c.id, title: c.title })),
|
|
122
|
+
labels: b.labels.map((l) => ({ name: l.name, color: l.color })),
|
|
123
|
+
cards: b.cards.map((c) => ({
|
|
124
|
+
id: c.id,
|
|
125
|
+
columnId: c.columnId,
|
|
126
|
+
title: c.title,
|
|
127
|
+
note: c.note ?? '',
|
|
128
|
+
label: c.label ?? null,
|
|
129
|
+
priority: c.priority ?? null,
|
|
130
|
+
})),
|
|
131
|
+
})
|
|
132
|
+
const summaryOfClone = (clone) => ({
|
|
133
|
+
columns: clone.columns.map((c) => ({
|
|
134
|
+
id: c.id,
|
|
135
|
+
title: c.title,
|
|
136
|
+
count: clone.cards.filter((k) => k.columnId === c.id).length,
|
|
137
|
+
})),
|
|
138
|
+
labels: clone.labels.map((l) => ({ name: l.name, color: l.color })),
|
|
139
|
+
cards: clone.cards.map((c) => ({
|
|
140
|
+
id: c.id,
|
|
141
|
+
columnId: c.columnId,
|
|
142
|
+
title: c.title,
|
|
143
|
+
label: c.label ?? null,
|
|
144
|
+
priority: c.priority ?? null,
|
|
145
|
+
})),
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// ---- 核心数据操作:工具与浏览器 HTTP 共用同一份逻辑 ----
|
|
149
|
+
const dispatch = async (wsid, method, args) => {
|
|
150
|
+
const board = await boardOf(wsid)
|
|
151
|
+
const a = args || {}
|
|
152
|
+
const persisted = () => persistedFlag(wsid)
|
|
153
|
+
const result = (extra) => ({ board: cloneBoard(board), persisted: persisted(), ...extra })
|
|
154
|
+
|
|
155
|
+
switch (method) {
|
|
156
|
+
case 'get':
|
|
157
|
+
return { board: cloneBoard(board), persisted: persisted(), message: 'Board loaded' }
|
|
158
|
+
|
|
159
|
+
case 'addCard': {
|
|
160
|
+
const col = findColumn(board, str(a.columnId, '')) || board.columns[0]
|
|
161
|
+
if (!col) return result({ error: 'No list available' })
|
|
162
|
+
board.cards.push({
|
|
163
|
+
id: nextId('k'),
|
|
164
|
+
columnId: col.id,
|
|
165
|
+
title: str(a.title, '').slice(0, 120) || 'Untitled card',
|
|
166
|
+
note: str(a.note, '').slice(0, 500),
|
|
167
|
+
label: typeof a.label === 'string' ? a.label.slice(0, 20) : undefined,
|
|
168
|
+
priority: normPriority(a.priority),
|
|
169
|
+
})
|
|
170
|
+
await save(wsid)
|
|
171
|
+
return result({ message: 'Card added to "' + col.title + '"' })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case 'updateCard': {
|
|
175
|
+
const card = findCard(board, str(a.id, ''))
|
|
176
|
+
if (card) {
|
|
177
|
+
if (typeof a.title === 'string') card.title = a.title.slice(0, 120) || card.title
|
|
178
|
+
if (typeof a.note === 'string') card.note = a.note.slice(0, 500)
|
|
179
|
+
if (typeof a.label === 'string') card.label = a.label.slice(0, 20) || undefined
|
|
180
|
+
if (typeof a.priority === 'string') card.priority = normPriority(a.priority)
|
|
181
|
+
await save(wsid)
|
|
182
|
+
}
|
|
183
|
+
return card
|
|
184
|
+
? result({ message: 'Card updated' })
|
|
185
|
+
: result({ error: 'Card not found: ' + str(a.id, '') })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case 'deleteCard': {
|
|
189
|
+
const id = str(a.id, '')
|
|
190
|
+
board.cards = board.cards.filter((c) => c.id !== id)
|
|
191
|
+
await save(wsid)
|
|
192
|
+
return result({ message: 'Card deleted' })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case 'moveCard': {
|
|
196
|
+
const card = findCard(board, str(a.id, ''))
|
|
197
|
+
const target = findColumn(board, str(a.columnId, ''))
|
|
198
|
+
if (!card || !target) return result({ error: 'Card or list not found' })
|
|
199
|
+
board.cards = board.cards.filter((c) => c.id !== card.id)
|
|
200
|
+
card.columnId = target.id
|
|
201
|
+
const inCol = board.cards.filter((c) => c.columnId === target.id)
|
|
202
|
+
const toIndex = typeof a.toIndex === 'number' && Number.isFinite(a.toIndex)
|
|
203
|
+
? Math.max(0, Math.min(Math.floor(a.toIndex), inCol.length))
|
|
204
|
+
: inCol.length
|
|
205
|
+
const anchor = inCol[toIndex]
|
|
206
|
+
if (anchor) board.cards.splice(board.cards.indexOf(anchor), 0, card)
|
|
207
|
+
else board.cards.push(card)
|
|
208
|
+
await save(wsid)
|
|
209
|
+
return result({ message: 'Moved to "' + target.title + '"' })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
case 'addColumn': {
|
|
213
|
+
const title = str(a.title, '').slice(0, 40) || 'New list'
|
|
214
|
+
board.columns.push({ id: nextId('c'), title })
|
|
215
|
+
await save(wsid)
|
|
216
|
+
return result({ message: 'List added: "' + title + '"' })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
case 'renameColumn': {
|
|
220
|
+
const col = findColumn(board, str(a.id, ''))
|
|
221
|
+
if (col && typeof a.title === 'string') {
|
|
222
|
+
col.title = a.title.slice(0, 40) || col.title
|
|
223
|
+
await save(wsid)
|
|
224
|
+
}
|
|
225
|
+
return col ? result({ message: 'List renamed' }) : result({ error: 'List not found' })
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
case 'deleteColumn': {
|
|
229
|
+
const id = str(a.id, '')
|
|
230
|
+
if (board.columns.length <= 1) return result({ error: 'At least one list must remain' })
|
|
231
|
+
const idx = board.columns.findIndex((c) => c.id === id)
|
|
232
|
+
if (idx < 0) return result({ error: 'List not found' })
|
|
233
|
+
board.columns.splice(idx, 1)
|
|
234
|
+
const fallback = board.columns[0].id
|
|
235
|
+
for (const card of board.cards) {
|
|
236
|
+
if (card.columnId === id) card.columnId = fallback
|
|
237
|
+
}
|
|
238
|
+
await save(wsid)
|
|
239
|
+
return result({ message: 'List deleted, cards moved to "' + board.columns[0].title + '"' })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case 'moveColumn': {
|
|
243
|
+
const id = str(a.id, '')
|
|
244
|
+
const idx = board.columns.findIndex((c) => c.id === id)
|
|
245
|
+
if (idx < 0) return result({ error: 'List not found' })
|
|
246
|
+
const [col] = board.columns.splice(idx, 1)
|
|
247
|
+
const toIndex = typeof a.toIndex === 'number' && Number.isFinite(a.toIndex)
|
|
248
|
+
? Math.max(0, Math.min(Math.floor(a.toIndex), board.columns.length))
|
|
249
|
+
: board.columns.length
|
|
250
|
+
board.columns.splice(toIndex, 0, col)
|
|
251
|
+
await save(wsid)
|
|
252
|
+
return result({ message: 'List order updated' })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case 'addLabel': {
|
|
256
|
+
const name = str(a.name, '').slice(0, 20)
|
|
257
|
+
if (!name) return result({ error: 'Label name required' })
|
|
258
|
+
if (findLabel(board, name)) return result({ error: 'Label already exists' })
|
|
259
|
+
board.labels.push({ name, color: normColor(a.color) || '#94a3b8' })
|
|
260
|
+
await save(wsid)
|
|
261
|
+
return result({ message: 'Label added: "' + name + '"' })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
case 'updateLabel': {
|
|
265
|
+
const name = str(a.name, '')
|
|
266
|
+
const label = findLabel(board, name)
|
|
267
|
+
if (!label) return result({ error: 'Label not found' })
|
|
268
|
+
const newName = str(a.newName, '').slice(0, 20)
|
|
269
|
+
if (newName && newName !== name) {
|
|
270
|
+
if (findLabel(board, newName)) return result({ error: 'Label name already exists' })
|
|
271
|
+
label.name = newName
|
|
272
|
+
for (const card of board.cards) {
|
|
273
|
+
if (card.label === name) card.label = newName
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (typeof a.color === 'string') label.color = normColor(a.color) || label.color
|
|
277
|
+
await save(wsid)
|
|
278
|
+
return result({ message: 'Label updated' })
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case 'deleteLabel': {
|
|
282
|
+
const name = str(a.name, '')
|
|
283
|
+
const idx = board.labels.findIndex((l) => l.name === name)
|
|
284
|
+
if (idx < 0) return result({ error: 'Label not found' })
|
|
285
|
+
board.labels.splice(idx, 1)
|
|
286
|
+
for (const card of board.cards) {
|
|
287
|
+
if (card.label === name) card.label = undefined
|
|
288
|
+
}
|
|
289
|
+
await save(wsid)
|
|
290
|
+
return result({ message: 'Label deleted' })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
default:
|
|
294
|
+
return result({ error: 'Unknown kanban method: ' + method })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---- 工具执行上下文 -> 工作区 id ----
|
|
299
|
+
const wsidOfExec = async (exec) => {
|
|
300
|
+
const agent = exec && exec.agent
|
|
301
|
+
const cwd = agent && agent.session && agent.session.header && agent.session.header.cwd
|
|
302
|
+
if (typeof cwd === 'string' && cwd) {
|
|
303
|
+
const registry = getWorkspaceRegistry()
|
|
304
|
+
if (registry) {
|
|
305
|
+
try {
|
|
306
|
+
const ws = await registry.resolveByPath(cwd)
|
|
307
|
+
if (ws) return ws.id
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.log('dsh-kanban: 解析工作区失败:' + ((err && err.message) || err))
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return 'default'
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const runTool = async (method, args, exec) => {
|
|
317
|
+
const wsid = await wsidOfExec(exec)
|
|
318
|
+
const r = await dispatch(wsid, method, args)
|
|
319
|
+
return { ok: !r.error, message: r.error || r.message || 'Done', board: summaryOfClone(r.board) }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---- 浏览器数据层:经官方 webServer 扩展点注册 /api/kanban ----
|
|
323
|
+
const httpHandler = async (req, res) => {
|
|
324
|
+
try {
|
|
325
|
+
const chunks = []
|
|
326
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
327
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
328
|
+
const body = raw ? JSON.parse(raw) : {}
|
|
329
|
+
const method = typeof body.method === 'string' ? body.method : 'get'
|
|
330
|
+
const args = body.args || {}
|
|
331
|
+
const wsid = typeof args.workspaceId === 'string' && args.workspaceId ? args.workspaceId : 'default'
|
|
332
|
+
const result = await dispatch(wsid, method, args)
|
|
333
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
334
|
+
res.end(JSON.stringify(result))
|
|
335
|
+
} catch (err) {
|
|
336
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
337
|
+
res.end(JSON.stringify({ error: String((err && err.message) || err) }))
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const routeState = { registered: false, timer: null, attempts: 0 }
|
|
342
|
+
const registerRoute = () => {
|
|
343
|
+
if (routeState.registered) return
|
|
344
|
+
const webServer = ctx.get('webServer')
|
|
345
|
+
if (webServer === undefined) return
|
|
346
|
+
try {
|
|
347
|
+
webServer.register({ kind: 'prefix', path: '/api/kanban', handler: httpHandler })
|
|
348
|
+
routeState.registered = true
|
|
349
|
+
console.log('dsh-kanban: /api/kanban 路由已注册')
|
|
350
|
+
} catch (err) {
|
|
351
|
+
console.log('dsh-kanban: 路由注册失败:' + ((err && err.message) || err))
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
registerRoute()
|
|
355
|
+
if (!routeState.registered) {
|
|
356
|
+
const timer = ctx.get('timer')
|
|
357
|
+
if (timer) {
|
|
358
|
+
routeState.timer = timer.interval(() => {
|
|
359
|
+
routeState.attempts++
|
|
360
|
+
registerRoute()
|
|
361
|
+
if (routeState.registered || routeState.attempts >= 40) {
|
|
362
|
+
if (routeState.timer) routeState.timer()
|
|
363
|
+
}
|
|
364
|
+
}, 500)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---- 工具注册 ----
|
|
369
|
+
const resultSchema = {
|
|
370
|
+
type: 'object',
|
|
371
|
+
properties: {
|
|
372
|
+
ok: { type: 'boolean' },
|
|
373
|
+
message: { type: 'string' },
|
|
374
|
+
board: { type: 'object' },
|
|
375
|
+
},
|
|
376
|
+
required: ['ok', 'message'],
|
|
377
|
+
additionalProperties: false,
|
|
378
|
+
}
|
|
379
|
+
const renderBoard = (value) => {
|
|
380
|
+
const b = value && value.board
|
|
381
|
+
const lines = [String((value && value.message) || '')]
|
|
382
|
+
if (b && Array.isArray(b.columns)) {
|
|
383
|
+
lines.push('Board state:')
|
|
384
|
+
for (const col of b.columns) {
|
|
385
|
+
lines.push('· ' + col.title + ' (' + col.count + ')')
|
|
386
|
+
}
|
|
387
|
+
if (Array.isArray(b.labels) && b.labels.length > 0) {
|
|
388
|
+
lines.push('Labels: ' + b.labels.map((l) => l.name).join(' / '))
|
|
389
|
+
}
|
|
390
|
+
if (Array.isArray(b.cards)) {
|
|
391
|
+
for (const card of b.cards) {
|
|
392
|
+
lines.push(
|
|
393
|
+
' - [' + card.id + '] ' +
|
|
394
|
+
(card.priority ? '[' + card.priority + '] ' : '') +
|
|
395
|
+
(card.label ? '[' + card.label + '] ' : '') +
|
|
396
|
+
card.title,
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
402
|
+
}
|
|
403
|
+
const output = (render) => ({ schema: resultSchema, render: (args, value) => render(value) })
|
|
404
|
+
|
|
405
|
+
const tools = [
|
|
406
|
+
{
|
|
407
|
+
name: 'kanban_get',
|
|
408
|
+
description: 'Read the current state of the current project (workspace) kanban board (all lists, labels and cards). Call it before planning to understand existing content and avoid duplicate cards.',
|
|
409
|
+
parameters: { type: 'object', properties: {} },
|
|
410
|
+
output: output(renderBoard),
|
|
411
|
+
async execute(args, exec) {
|
|
412
|
+
return runTool('get', args, exec)
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
name: 'kanban_add_card',
|
|
417
|
+
description: 'Add a card to the current project (workspace) kanban board. Write feature breakdowns and plans to the board: one card per task step.',
|
|
418
|
+
parameters: {
|
|
419
|
+
type: 'object',
|
|
420
|
+
properties: {
|
|
421
|
+
title: { type: 'string', description: 'Card title (task name, concise and actionable)' },
|
|
422
|
+
columnId: { type: 'string', description: 'Target list id; defaults to the first list (Todo)' },
|
|
423
|
+
note: { type: 'string', description: 'Note: background, acceptance criteria or breakdown details (optional)' },
|
|
424
|
+
label: { type: 'string', description: 'Label name (optional): e.g. New Feature, bug, Feedback' },
|
|
425
|
+
priority: { type: 'string', enum: ['high', 'medium', 'low'], description: 'Priority (optional): high=P0 / medium=P1 / low=P2' },
|
|
426
|
+
},
|
|
427
|
+
required: ['title'],
|
|
428
|
+
},
|
|
429
|
+
output: output(renderBoard),
|
|
430
|
+
async execute(args, exec) {
|
|
431
|
+
return runTool('addCard', args, exec)
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
name: 'kanban_update_card',
|
|
436
|
+
description: "Update a card's title, note, label or priority on the board.",
|
|
437
|
+
parameters: {
|
|
438
|
+
type: 'object',
|
|
439
|
+
properties: {
|
|
440
|
+
id: { type: 'string', description: 'Card id' },
|
|
441
|
+
title: { type: 'string', description: 'New title (optional)' },
|
|
442
|
+
note: { type: 'string', description: 'New note (optional)' },
|
|
443
|
+
label: { type: 'string', description: 'New label name (optional); pass empty string to clear' },
|
|
444
|
+
priority: { type: 'string', enum: ['high', 'medium', 'low'], description: 'New priority (optional): high=P0 / medium=P1 / low=P2; pass empty string to clear' },
|
|
445
|
+
},
|
|
446
|
+
required: ['id'],
|
|
447
|
+
},
|
|
448
|
+
output: output(renderBoard),
|
|
449
|
+
async execute(args, exec) {
|
|
450
|
+
return runTool('updateCard', args, exec)
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
name: 'kanban_delete_card',
|
|
455
|
+
description: 'Delete a card from the board (permanent, not recoverable).',
|
|
456
|
+
parameters: {
|
|
457
|
+
type: 'object',
|
|
458
|
+
properties: { id: { type: 'string', description: 'Card id' } },
|
|
459
|
+
required: ['id'],
|
|
460
|
+
},
|
|
461
|
+
output: output(renderBoard),
|
|
462
|
+
async execute(args, exec) {
|
|
463
|
+
return runTool('deleteCard', args, exec)
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
name: 'kanban_move_card',
|
|
468
|
+
description: 'Move a card to the specified list (e.g. from Todo to In Progress). Use when a task status changes.',
|
|
469
|
+
parameters: {
|
|
470
|
+
type: 'object',
|
|
471
|
+
properties: {
|
|
472
|
+
id: { type: 'string', description: 'Card id' },
|
|
473
|
+
columnId: { type: 'string', description: 'Target list id' },
|
|
474
|
+
toIndex: { type: 'integer', description: 'Position within the target list (optional); takes effect when reordering within the same list, 0 = top' },
|
|
475
|
+
},
|
|
476
|
+
required: ['id', 'columnId'],
|
|
477
|
+
},
|
|
478
|
+
output: output(renderBoard),
|
|
479
|
+
async execute(args, exec) {
|
|
480
|
+
return runTool('moveCard', args, exec)
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
name: 'kanban_add_column',
|
|
485
|
+
description: 'Add a list (column) to the board. Use when a new workflow stage (e.g. Review, Blocked) is needed.',
|
|
486
|
+
parameters: {
|
|
487
|
+
type: 'object',
|
|
488
|
+
properties: { title: { type: 'string', description: 'List name' } },
|
|
489
|
+
required: ['title'],
|
|
490
|
+
},
|
|
491
|
+
output: output(renderBoard),
|
|
492
|
+
async execute(args, exec) {
|
|
493
|
+
return runTool('addColumn', args, exec)
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
name: 'kanban_rename_column',
|
|
498
|
+
description: 'Rename a list on the board.',
|
|
499
|
+
parameters: {
|
|
500
|
+
type: 'object',
|
|
501
|
+
properties: {
|
|
502
|
+
id: { type: 'string', description: 'List id' },
|
|
503
|
+
title: { type: 'string', description: 'New name' },
|
|
504
|
+
},
|
|
505
|
+
required: ['id', 'title'],
|
|
506
|
+
},
|
|
507
|
+
output: output(renderBoard),
|
|
508
|
+
async execute(args, exec) {
|
|
509
|
+
return runTool('renameColumn', args, exec)
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
name: 'kanban_delete_column',
|
|
514
|
+
description: 'Delete a list from the board; its cards are moved into the first list. At least one list must remain.',
|
|
515
|
+
parameters: {
|
|
516
|
+
type: 'object',
|
|
517
|
+
properties: { id: { type: 'string', description: 'List id' } },
|
|
518
|
+
required: ['id'],
|
|
519
|
+
},
|
|
520
|
+
output: output(renderBoard),
|
|
521
|
+
async execute(args, exec) {
|
|
522
|
+
return runTool('deleteColumn', args, exec)
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
name: 'kanban_move_column',
|
|
527
|
+
description: 'Reorder a list (column) on the board, moving it to the specified position.',
|
|
528
|
+
parameters: {
|
|
529
|
+
type: 'object',
|
|
530
|
+
properties: {
|
|
531
|
+
id: { type: 'string', description: 'List id to move' },
|
|
532
|
+
toIndex: { type: 'integer', description: 'Target position (0 = first list)' },
|
|
533
|
+
},
|
|
534
|
+
required: ['id', 'toIndex'],
|
|
535
|
+
},
|
|
536
|
+
output: output(renderBoard),
|
|
537
|
+
async execute(args, exec) {
|
|
538
|
+
return runTool('moveColumn', args, exec)
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
name: 'kanban_add_label',
|
|
543
|
+
description: 'Create a new label on the board (name bound to a color). Use to categorize cards.',
|
|
544
|
+
parameters: {
|
|
545
|
+
type: 'object',
|
|
546
|
+
properties: {
|
|
547
|
+
name: { type: 'string', description: 'Label name (unique, e.g. Urgent, Refactor)' },
|
|
548
|
+
color: { type: 'string', description: 'Label color (optional): #rrggbb hex; defaults to gray' },
|
|
549
|
+
},
|
|
550
|
+
required: ['name'],
|
|
551
|
+
},
|
|
552
|
+
output: output(renderBoard),
|
|
553
|
+
async execute(args, exec) {
|
|
554
|
+
return runTool('addLabel', args, exec)
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
name: 'kanban_update_label',
|
|
559
|
+
description: 'Modify a label on the board (rename or recolor). Renaming automatically updates cards referencing it.',
|
|
560
|
+
parameters: {
|
|
561
|
+
type: 'object',
|
|
562
|
+
properties: {
|
|
563
|
+
name: { type: 'string', description: 'Current label name' },
|
|
564
|
+
newName: { type: 'string', description: 'New name (optional)' },
|
|
565
|
+
color: { type: 'string', description: 'New color (optional): #rrggbb hex' },
|
|
566
|
+
},
|
|
567
|
+
required: ['name'],
|
|
568
|
+
},
|
|
569
|
+
output: output(renderBoard),
|
|
570
|
+
async execute(args, exec) {
|
|
571
|
+
return runTool('updateLabel', args, exec)
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
name: 'kanban_delete_label',
|
|
576
|
+
description: 'Delete a label from the board. Cards referencing it will have their label cleared.',
|
|
577
|
+
parameters: {
|
|
578
|
+
type: 'object',
|
|
579
|
+
properties: { name: { type: 'string', description: 'Label name' } },
|
|
580
|
+
required: ['name'],
|
|
581
|
+
},
|
|
582
|
+
output: output(renderBoard),
|
|
583
|
+
async execute(args, exec) {
|
|
584
|
+
return runTool('deleteLabel', args, exec)
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
name: 'kanban_get_label',
|
|
589
|
+
description: 'Read the label list (names and colors) of the current project (workspace) board. Check available labels before tagging cards.',
|
|
590
|
+
parameters: { type: 'object', properties: {} },
|
|
591
|
+
output: {
|
|
592
|
+
schema: {
|
|
593
|
+
type: 'object',
|
|
594
|
+
properties: {
|
|
595
|
+
ok: { type: 'boolean' },
|
|
596
|
+
message: { type: 'string' },
|
|
597
|
+
labels: { type: 'array', items: { type: 'object' } },
|
|
598
|
+
},
|
|
599
|
+
required: ['ok', 'message'],
|
|
600
|
+
additionalProperties: false,
|
|
601
|
+
},
|
|
602
|
+
render: (args, value) => {
|
|
603
|
+
const labels = value && value.labels
|
|
604
|
+
const lines = [String((value && value.message) || '')]
|
|
605
|
+
if (Array.isArray(labels)) {
|
|
606
|
+
for (const l of labels) lines.push('- ' + l.name + ' (' + l.color + ')')
|
|
607
|
+
}
|
|
608
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
async execute(args, exec) {
|
|
612
|
+
const wsid = await wsidOfExec(exec)
|
|
613
|
+
const r = await dispatch(wsid, 'get', args)
|
|
614
|
+
const labels = (r.board && r.board.labels) || []
|
|
615
|
+
return { ok: true, message: 'Labels (workspace ' + wsid + ')', labels }
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
]
|
|
619
|
+
|
|
620
|
+
for (const tool of tools) ctx.tools.register(tool)
|
|
621
|
+
}
|