@alpacachen/dsh-kanban 1.1.0 → 1.3.0

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/index.js CHANGED
@@ -7,18 +7,248 @@
7
7
  * 职责:
8
8
  * - 按工作区(项目)隔离:boards 以 workspaceId 为键,每个工作区一块独立看板
9
9
  * - 磁盘持久化:经 ctx.fs 写入 <workspaceRoot>/kanban-board-<workspaceId>.json
10
- * - 模型工具:经 ctx.tools.register 注册 8 个 kanban_* 工具
10
+ * - 模型工具:经 ctx.tools.register 注册 14 个 kanban_* 工具
11
11
  * - 浏览器数据层:经 ctx.get('webServer') 注册 /api/kanban 前缀路由
12
12
  *
13
- * 数据模型(每工作区):
13
+ * 数据模型(每工作区,磁盘文件带 schemaVersion):
14
+ * schemaVersion: 2
14
15
  * columns: [{ id, title }]
15
16
  * labels: [{ name, color }] —— 标签与颜色绑定,name 为唯一键
16
- * cards: [{ id, columnId, title, note, label, priority }]
17
+ * cards: [{ id, columnId, title, note, label, priority, createdAt, createdBy }]
18
+ * activities: [{ id, ts, cardId, type, source, field?, from?, to?, meta? }] —— 追加式活动日志
19
+ *
20
+ * 数据安全:
21
+ * - 无 schemaVersion 的历史文件按 v0 处理,首次打开自动升级(见 MIGRATIONS)
22
+ * - 升级/损坏/版本超前均先备份原文件(.bak-vN / .corrupt-<ts> / .unsupported-vN)
23
+ * - 启动时全量体检所有看板文件,损坏文件备份后不影响看板可用性
17
24
  */
18
25
  export const name = 'dsh-kanban'
19
26
 
20
27
  export const inject = ['tools']
21
28
 
29
+ // ---------------------------------------------------------------------------
30
+ // 持久化格式版本与迁移
31
+ //
32
+ // 磁盘文件结构:
33
+ // { schemaVersion, columns, labels, cards }
34
+ //
35
+ // 版本约定:
36
+ // v1 —— 首个带版本声明的格式,等价于 1.0.x ~ 1.2.x 时代无版本声明的三数组格式
37
+ // (columns/labels/cards),并补齐了卡片的规范字段(note/label/priority)。
38
+ // LEGACY_VERSION(0) —— 历史文件没有 schemaVersion 字段,一律归入 v0 处理。
39
+ //
40
+ // 新增/删除字段的流程:
41
+ // 1) SCHEMA_VERSION 自增
42
+ // 2) 在 MIGRATIONS 里以"旧版本号为键"注册 v(n)→v(n+1) 迁移函数
43
+ // 3) 迁移函数是纯函数,输出必须带 schemaVersion: n+1(migrateBoard 会校验)
44
+ // 旧文件在首次打开时自动沿迁移链升级,升级前原文件先备份。
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export const SCHEMA_VERSION = 2
48
+ export const LEGACY_VERSION = 0
49
+
50
+ // 活动日志:追加式只读记录,随看板一起落盘;超过上限丢弃最旧事件,防止日志无界增长。
51
+ const ACTIVITY_LIMIT = 5000
52
+
53
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
54
+ const strField = (v, fb) => (typeof v === 'string' && v ? v : fb)
55
+ const normColor = (v) => (typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v) ? v.toLowerCase() : '#94a3b8')
56
+
57
+ // v1 的规范实体形状(仅补缺省值,不改动合法数据)
58
+ const normColumn = (c) =>
59
+ isObj(c) ? { id: strField(c.id, ''), title: strField(c.title, 'Untitled') } : null
60
+ const normLabel = (l) =>
61
+ isObj(l) ? { name: strField(l.name, ''), color: normColor(l.color) } : null
62
+ const normCard = (c) =>
63
+ isObj(c)
64
+ ? {
65
+ id: strField(c.id, ''),
66
+ columnId: strField(c.columnId, ''),
67
+ title: strField(c.title, 'Untitled'),
68
+ note: typeof c.note === 'string' ? c.note : '',
69
+ label: typeof c.label === 'string' && c.label ? c.label : null,
70
+ priority: typeof c.priority === 'string' ? c.priority : null,
71
+ }
72
+ : null
73
+
74
+ /**
75
+ * 逐版本迁移注册表:key = 旧版本号,value = (旧数据) => 新数据。
76
+ * 输出必须设置 schemaVersion = key + 1,migrateBoard 会逐级校验。
77
+ */
78
+ export const MIGRATIONS = {
79
+ 0: (data) => {
80
+ // v0 → v1:历史无版本文件 —— 声明版本 + 规范化实体字段
81
+ const src = isObj(data) ? data : {}
82
+ const pick = (arr) => (Array.isArray(arr) ? arr : [])
83
+ return {
84
+ schemaVersion: 1,
85
+ columns: pick(src.columns).map(normColumn).filter(Boolean),
86
+ labels: pick(src.labels).map(normLabel).filter(Boolean),
87
+ cards: pick(src.cards).map(normCard).filter(Boolean),
88
+ }
89
+ },
90
+ 1: (data) => {
91
+ // v1 → v2:新增追加式活动日志 activities;卡片补 createdAt/createdBy(历史数据为 null)
92
+ const src = isObj(data) ? data : {}
93
+ const pick = (arr) => (Array.isArray(arr) ? arr : [])
94
+ return {
95
+ schemaVersion: 2,
96
+ columns: pick(src.columns).map(normColumn).filter(Boolean),
97
+ labels: pick(src.labels).map(normLabel).filter(Boolean),
98
+ cards: pick(src.cards)
99
+ .map((c) => {
100
+ const card = normCard(c)
101
+ return card ? { ...card, createdAt: null, createdBy: null } : null
102
+ })
103
+ .filter(Boolean),
104
+ activities: [],
105
+ }
106
+ },
107
+ }
108
+
109
+ /**
110
+ * 沿迁移链把数据从 fromVersion 逐级升级到 SCHEMA_VERSION。
111
+ * 任一步缺失或产出无效都会抛错(由调用方备份并降级处理)。
112
+ */
113
+ export function migrateBoard(data, fromVersion) {
114
+ let out = data
115
+ let v = fromVersion
116
+ while (v < SCHEMA_VERSION) {
117
+ const step = MIGRATIONS[v]
118
+ if (typeof step !== 'function') {
119
+ throw new Error('missing migration v' + v + ' -> v' + (v + 1))
120
+ }
121
+ out = step(out)
122
+ v += 1
123
+ if (!isObj(out) || out.schemaVersion !== v) {
124
+ throw new Error('migration v' + (v - 1) + ' -> v' + v + ' produced invalid data')
125
+ }
126
+ }
127
+ return out
128
+ }
129
+
130
+ /**
131
+ * 结构校验(迁移后的最终形态)。
132
+ * 返回 { ok, errors };errors 非空时文件应视为损坏处理。
133
+ */
134
+ export function validateBoard(data) {
135
+ const errors = []
136
+ if (!isObj(data)) {
137
+ errors.push('board is not an object')
138
+ return { ok: false, errors }
139
+ }
140
+ if (data.schemaVersion !== SCHEMA_VERSION) {
141
+ errors.push('expected schemaVersion ' + SCHEMA_VERSION + ', got ' + String(data.schemaVersion))
142
+ }
143
+ if (!Array.isArray(data.columns)) errors.push('columns must be an array')
144
+ if (!Array.isArray(data.labels)) errors.push('labels must be an array')
145
+ if (!Array.isArray(data.cards)) errors.push('cards must be an array')
146
+ if (!Array.isArray(data.activities)) errors.push('activities must be an array')
147
+ if (Array.isArray(data.cards)) {
148
+ const seen = new Set()
149
+ for (const c of data.cards) {
150
+ if (!isObj(c) || typeof c.id !== 'string' || !c.id) {
151
+ errors.push('cards contain an entry without a valid id')
152
+ break
153
+ }
154
+ if (seen.has(c.id)) {
155
+ errors.push('duplicate card id: ' + c.id)
156
+ break
157
+ }
158
+ seen.add(c.id)
159
+ }
160
+ }
161
+ return { ok: errors.length === 0, errors }
162
+ }
163
+
164
+ /**
165
+ * 解析并升级一段看板文件文本(纯函数,不触磁盘)。
166
+ *
167
+ * 返回:
168
+ * { ok: true, kind: 'ok', data, migrated, fromVersion, warnings } —— data 为可用的最新版
169
+ * { ok: false, kind: 'corrupt'|'invalid'|'unsupported', warnings } —— 需要调用方备份原文件
170
+ *
171
+ * kind 语义:
172
+ * corrupt —— JSON 无法解析
173
+ * invalid —— 结构校验失败 / 迁移失败
174
+ * unsupported —— schemaVersion 高于当前插件支持(文件来自更新版本插件)
175
+ */
176
+ export function parseBoardText(text) {
177
+ const warnings = []
178
+ let data
179
+ try {
180
+ data = JSON.parse(text)
181
+ } catch {
182
+ return {
183
+ ok: false,
184
+ kind: 'corrupt',
185
+ warnings: [
186
+ 'Board data file could not be parsed as JSON; the original file has been backed up and the board opened empty.',
187
+ ],
188
+ }
189
+ }
190
+
191
+ const version =
192
+ isObj(data) && typeof data.schemaVersion === 'number' ? data.schemaVersion : LEGACY_VERSION
193
+
194
+ if (version > SCHEMA_VERSION) {
195
+ return {
196
+ ok: false,
197
+ kind: 'unsupported',
198
+ version,
199
+ warnings: [
200
+ 'Board data file was written by a newer plugin version (schemaVersion ' +
201
+ version +
202
+ '); this plugin supports up to ' +
203
+ SCHEMA_VERSION +
204
+ '. The original file has been backed up and the board opened empty — please upgrade the plugin to read the original data.',
205
+ ],
206
+ }
207
+ }
208
+
209
+ let migrated = false
210
+ if (version < SCHEMA_VERSION) {
211
+ try {
212
+ data = migrateBoard(data, version)
213
+ migrated = true
214
+ warnings.push(
215
+ 'Board data was automatically upgraded from schemaVersion ' +
216
+ version +
217
+ ' to ' +
218
+ SCHEMA_VERSION +
219
+ '; the pre-upgrade file has been backed up.',
220
+ )
221
+ } catch (err) {
222
+ return {
223
+ ok: false,
224
+ kind: 'invalid',
225
+ version,
226
+ warnings: [
227
+ 'Board data upgrade failed (' +
228
+ ((err && err.message) || err) +
229
+ '); the original file has been backed up and the board opened empty.',
230
+ ],
231
+ }
232
+ }
233
+ }
234
+
235
+ const check = validateBoard(data)
236
+ if (!check.ok) {
237
+ return {
238
+ ok: false,
239
+ kind: 'invalid',
240
+ version: isObj(data) ? data.schemaVersion : undefined,
241
+ warnings: [
242
+ 'Board data file structure is invalid (' +
243
+ check.errors.join('; ') +
244
+ '); the original file has been backed up and the board opened empty.',
245
+ ],
246
+ }
247
+ }
248
+
249
+ return { ok: true, kind: 'ok', data, migrated, fromVersion: version, warnings }
250
+ }
251
+
22
252
  export function apply(ctx) {
23
253
  const getFs = () => ctx.get('fs')
24
254
  const getPolicy = () => ctx.get('sandboxPolicy')
@@ -67,33 +297,81 @@ export function apply(ctx) {
67
297
  const persistedFlag = (wsid) => fileTargets.has(wsid) && fileTargets.get(wsid) !== null
68
298
 
69
299
  // ---- 看板读写 ----
300
+
301
+ // 备份原文件为 <文件名>.<suffix>(复制而非移动,保证原文件在写回前始终存在)
302
+ const backupFile = async (wsid, suffix) => {
303
+ const fs = getFs()
304
+ const target = await targetOf(wsid)
305
+ if (!fs || !target) return null
306
+ try {
307
+ const text = await fs.readText(target)
308
+ const backupTarget = await fs.resolve(fileName(wsid) + '.' + suffix, root() ? { cwd: root() } : {})
309
+ await fs.writeText(backupTarget, text)
310
+ return backupTarget
311
+ } catch (err) {
312
+ console.log('dsh-kanban: 备份失败 ' + wsid + ' (' + suffix + '):' + ((err && err.message) || err))
313
+ return null
314
+ }
315
+ }
316
+
317
+ // 记录一次性警告:进入看板 warnings 队列 + 写宿主日志
318
+ const warn = (board, message) => {
319
+ if (Array.isArray(board.warnings)) board.warnings.push(message)
320
+ console.log('dsh-kanban: ' + message)
321
+ }
322
+ const takeWarnings = (board) => {
323
+ const w = Array.isArray(board.warnings) ? board.warnings : []
324
+ board.warnings = []
325
+ return w
326
+ }
327
+ const timestamp = () => new Date().toISOString().replace(/[:.]/g, '-')
328
+
329
+ // 首次访问某工作区时从磁盘加载;异常数据一律不阻塞看板可用性
70
330
  const boardOf = async (wsid) => {
71
331
  let board = boards.get(wsid)
72
332
  if (board) return board
73
- board = { columns: [], labels: [], cards: [] }
333
+ board = { schemaVersion: SCHEMA_VERSION, columns: [], labels: [], cards: [], activities: [], warnings: [] }
74
334
  boards.set(wsid, board)
75
335
  const fs = getFs()
76
336
  const target = await targetOf(wsid)
337
+ let migrated = false
77
338
  if (fs && target) {
78
339
  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 : []
340
+ const text = await fs.readText(target)
341
+ const parsed = parseBoardText(text)
342
+ for (const w of parsed.warnings) warn(board, w)
343
+ if (parsed.ok) {
344
+ board.columns = parsed.data.columns
345
+ board.labels = parsed.data.labels
346
+ board.cards = parsed.data.cards
347
+ board.activities = Array.isArray(parsed.data.activities) ? parsed.data.activities : []
348
+ migrated = parsed.migrated
349
+ // 迁移场景:写回前先把迁移前的原文件备份下来,保证可回滚
350
+ if (parsed.migrated) {
351
+ await backupFile(wsid, 'bak-v' + parsed.fromVersion)
352
+ }
353
+ } else {
354
+ // 损坏 / 结构无效 / 版本超前:原文件已无法安全读取,先备份再以空板继续
355
+ const suffix =
356
+ parsed.kind === 'unsupported' ? 'unsupported-v' + parsed.version : 'corrupt-' + timestamp()
357
+ await backupFile(wsid, suffix)
84
358
  }
85
359
  } catch (err) {
86
- // 尚无看板文件(首次使用),保留默认空板
360
+ // 尚无看板文件(首次使用)或 fs 读取异常,保留默认空板
361
+ console.log('dsh-kanban: 读取看板失败 ' + wsid + ':' + ((err && err.message) || err))
87
362
  }
88
363
  }
89
364
  for (const col of board.columns) bumpSeq(col.id)
90
365
  for (const card of board.cards) bumpSeq(card.id)
366
+ for (const act of board.activities) bumpSeq(act.id)
91
367
  if (board.columns.length === 0) {
92
368
  for (const title of DEFAULT_COLUMNS) board.columns.push({ id: nextId('c'), title })
93
369
  }
94
370
  if (board.labels.length === 0) {
95
371
  board.labels = DEFAULT_LABELS.map((l) => ({ ...l }))
96
372
  }
373
+ // 升级写回:迁移成功即落盘新版本,保证每个文件只迁移一次
374
+ if (migrated && fs && target) await save(wsid)
97
375
  return board
98
376
  }
99
377
  const save = async (wsid) => {
@@ -102,7 +380,16 @@ export function apply(ctx) {
102
380
  const board = boards.get(wsid)
103
381
  if (!fs || !target || !board) return
104
382
  try {
105
- await fs.writeText(target, JSON.stringify({ columns: board.columns, labels: board.labels, cards: board.cards }))
383
+ await fs.writeText(
384
+ target,
385
+ JSON.stringify({
386
+ schemaVersion: SCHEMA_VERSION,
387
+ columns: board.columns,
388
+ labels: board.labels,
389
+ cards: board.cards,
390
+ activities: Array.isArray(board.activities) ? board.activities : [],
391
+ }),
392
+ )
106
393
  } catch (err) {
107
394
  console.log('dsh-kanban: 保存失败 ' + wsid + ':' + ((err && err.message) || err))
108
395
  }
@@ -127,7 +414,10 @@ export function apply(ctx) {
127
414
  note: c.note ?? '',
128
415
  label: c.label ?? null,
129
416
  priority: c.priority ?? null,
417
+ createdAt: typeof c.createdAt === 'string' ? c.createdAt : null,
418
+ createdBy: typeof c.createdBy === 'string' ? c.createdBy : null,
130
419
  })),
420
+ activities: Array.isArray(b.activities) ? b.activities.map((a) => ({ ...a })) : [],
131
421
  })
132
422
  const summaryOfClone = (clone) => ({
133
423
  columns: clone.columns.map((c) => ({
@@ -145,27 +435,61 @@ export function apply(ctx) {
145
435
  })),
146
436
  })
147
437
 
438
+ // 追加一条活动事件(只读日志,随看板一起落盘)
439
+ const record = (board, ev) => {
440
+ if (!Array.isArray(board.activities)) board.activities = []
441
+ board.activities.push({
442
+ id: nextId('e'),
443
+ ts: new Date().toISOString(),
444
+ ...ev,
445
+ })
446
+ if (board.activities.length > ACTIVITY_LIMIT) {
447
+ board.activities.splice(0, board.activities.length - ACTIVITY_LIMIT)
448
+ }
449
+ }
450
+
148
451
  // ---- 核心数据操作:工具与浏览器 HTTP 共用同一份逻辑 ----
149
- const dispatch = async (wsid, method, args) => {
452
+ const dispatch = async (wsid, method, args, source) => {
150
453
  const board = await boardOf(wsid)
151
454
  const a = args || {}
455
+ const actor = source === 'agent' ? 'agent' : 'human'
152
456
  const persisted = () => persistedFlag(wsid)
153
- const result = (extra) => ({ board: cloneBoard(board), persisted: persisted(), ...extra })
457
+ const result = (extra) => ({ board: cloneBoard(board), persisted: persisted(), warnings: takeWarnings(board), ...extra })
154
458
 
155
459
  switch (method) {
156
460
  case 'get':
157
- return { board: cloneBoard(board), persisted: persisted(), message: 'Board loaded' }
461
+ return { board: cloneBoard(board), persisted: persisted(), warnings: takeWarnings(board), message: 'Board loaded' }
462
+
463
+ case 'getCard': {
464
+ const card = cloneBoard(board).cards.find((c) => c.id === str(a.id, ''))
465
+ if (!card) return { warnings: takeWarnings(board), error: 'Card not found: ' + str(a.id, '') }
466
+ return { card, warnings: takeWarnings(board) }
467
+ }
158
468
 
159
469
  case 'addCard': {
160
470
  const col = findColumn(board, str(a.columnId, '')) || board.columns[0]
161
471
  if (!col) return result({ error: 'No list available' })
162
- board.cards.push({
472
+ const card = {
163
473
  id: nextId('k'),
164
474
  columnId: col.id,
165
475
  title: str(a.title, '').slice(0, 120) || 'Untitled card',
166
476
  note: str(a.note, '').slice(0, 500),
167
477
  label: typeof a.label === 'string' ? a.label.slice(0, 20) : undefined,
168
478
  priority: normPriority(a.priority),
479
+ createdAt: new Date().toISOString(),
480
+ createdBy: actor,
481
+ }
482
+ board.cards.push(card)
483
+ record(board, {
484
+ cardId: card.id,
485
+ type: 'card_created',
486
+ source: actor,
487
+ meta: {
488
+ title: card.title,
489
+ column: col.title,
490
+ label: card.label ?? null,
491
+ priority: card.priority ?? null,
492
+ },
169
493
  })
170
494
  await save(wsid)
171
495
  return result({ message: 'Card added to "' + col.title + '"' })
@@ -174,10 +498,34 @@ export function apply(ctx) {
174
498
  case 'updateCard': {
175
499
  const card = findCard(board, str(a.id, ''))
176
500
  if (card) {
501
+ const before = {
502
+ title: card.title,
503
+ note: card.note,
504
+ label: card.label ?? null,
505
+ priority: card.priority ?? null,
506
+ }
177
507
  if (typeof a.title === 'string') card.title = a.title.slice(0, 120) || card.title
178
508
  if (typeof a.note === 'string') card.note = a.note.slice(0, 500)
179
509
  if (typeof a.label === 'string') card.label = a.label.slice(0, 20) || undefined
180
510
  if (typeof a.priority === 'string') card.priority = normPriority(a.priority)
511
+ const after = {
512
+ title: card.title,
513
+ note: card.note,
514
+ label: card.label ?? null,
515
+ priority: card.priority ?? null,
516
+ }
517
+ if (after.title !== before.title) {
518
+ record(board, { cardId: card.id, type: 'card_title_changed', source: actor, field: 'title', from: before.title, to: after.title })
519
+ }
520
+ if (after.note !== before.note) {
521
+ record(board, { cardId: card.id, type: 'card_note_changed', source: actor, field: 'note' })
522
+ }
523
+ if (after.label !== before.label) {
524
+ record(board, { cardId: card.id, type: 'card_label_changed', source: actor, field: 'label', from: before.label, to: after.label })
525
+ }
526
+ if (after.priority !== before.priority) {
527
+ record(board, { cardId: card.id, type: 'card_priority_changed', source: actor, field: 'priority', from: before.priority, to: after.priority })
528
+ }
181
529
  await save(wsid)
182
530
  }
183
531
  return card
@@ -187,7 +535,11 @@ export function apply(ctx) {
187
535
 
188
536
  case 'deleteCard': {
189
537
  const id = str(a.id, '')
538
+ const card = findCard(board, id)
190
539
  board.cards = board.cards.filter((c) => c.id !== id)
540
+ if (card) {
541
+ record(board, { cardId: id, type: 'card_deleted', source: actor, meta: { title: card.title } })
542
+ }
191
543
  await save(wsid)
192
544
  return result({ message: 'Card deleted' })
193
545
  }
@@ -196,6 +548,8 @@ export function apply(ctx) {
196
548
  const card = findCard(board, str(a.id, ''))
197
549
  const target = findColumn(board, str(a.columnId, ''))
198
550
  if (!card || !target) return result({ error: 'Card or list not found' })
551
+ const fromCol = findColumn(board, card.columnId)
552
+ const fromTitle = fromCol ? fromCol.title : String(card.columnId)
199
553
  board.cards = board.cards.filter((c) => c.id !== card.id)
200
554
  card.columnId = target.id
201
555
  const inCol = board.cards.filter((c) => c.columnId === target.id)
@@ -205,6 +559,9 @@ export function apply(ctx) {
205
559
  const anchor = inCol[toIndex]
206
560
  if (anchor) board.cards.splice(board.cards.indexOf(anchor), 0, card)
207
561
  else board.cards.push(card)
562
+ if (fromTitle !== target.title) {
563
+ record(board, { cardId: card.id, type: 'card_moved', source: actor, field: 'columnId', from: fromTitle, to: target.title, meta: { title: card.title } })
564
+ }
208
565
  await save(wsid)
209
566
  return result({ message: 'Moved to "' + target.title + '"' })
210
567
  }
@@ -212,6 +569,7 @@ export function apply(ctx) {
212
569
  case 'addColumn': {
213
570
  const title = str(a.title, '').slice(0, 40) || 'New list'
214
571
  board.columns.push({ id: nextId('c'), title })
572
+ record(board, { cardId: null, type: 'column_added', source: actor, meta: { column: title } })
215
573
  await save(wsid)
216
574
  return result({ message: 'List added: "' + title + '"' })
217
575
  }
@@ -219,7 +577,11 @@ export function apply(ctx) {
219
577
  case 'renameColumn': {
220
578
  const col = findColumn(board, str(a.id, ''))
221
579
  if (col && typeof a.title === 'string') {
580
+ const before = col.title
222
581
  col.title = a.title.slice(0, 40) || col.title
582
+ if (col.title !== before) {
583
+ record(board, { cardId: null, type: 'column_renamed', source: actor, field: 'title', from: before, to: col.title, meta: { column: col.title } })
584
+ }
223
585
  await save(wsid)
224
586
  }
225
587
  return col ? result({ message: 'List renamed' }) : result({ error: 'List not found' })
@@ -230,11 +592,16 @@ export function apply(ctx) {
230
592
  if (board.columns.length <= 1) return result({ error: 'At least one list must remain' })
231
593
  const idx = board.columns.findIndex((c) => c.id === id)
232
594
  if (idx < 0) return result({ error: 'List not found' })
595
+ const deleted = board.columns[idx]
233
596
  board.columns.splice(idx, 1)
234
597
  const fallback = board.columns[0].id
235
598
  for (const card of board.cards) {
236
- if (card.columnId === id) card.columnId = fallback
599
+ if (card.columnId === id) {
600
+ card.columnId = fallback
601
+ record(board, { cardId: card.id, type: 'card_moved', source: actor, field: 'columnId', from: deleted.title, to: board.columns[0].title, meta: { title: card.title } })
602
+ }
237
603
  }
604
+ record(board, { cardId: null, type: 'column_deleted', source: actor, meta: { column: deleted.title } })
238
605
  await save(wsid)
239
606
  return result({ message: 'List deleted, cards moved to "' + board.columns[0].title + '"' })
240
607
  }
@@ -257,6 +624,7 @@ export function apply(ctx) {
257
624
  if (!name) return result({ error: 'Label name required' })
258
625
  if (findLabel(board, name)) return result({ error: 'Label already exists' })
259
626
  board.labels.push({ name, color: normColor(a.color) || '#94a3b8' })
627
+ record(board, { cardId: null, type: 'label_added', source: actor, meta: { label: name } })
260
628
  await save(wsid)
261
629
  return result({ message: 'Label added: "' + name + '"' })
262
630
  }
@@ -270,10 +638,20 @@ export function apply(ctx) {
270
638
  if (findLabel(board, newName)) return result({ error: 'Label name already exists' })
271
639
  label.name = newName
272
640
  for (const card of board.cards) {
273
- if (card.label === name) card.label = newName
641
+ if (card.label === name) {
642
+ card.label = newName
643
+ record(board, { cardId: card.id, type: 'card_label_changed', source: actor, field: 'label', from: name, to: newName, meta: { title: card.title } })
644
+ }
645
+ }
646
+ record(board, { cardId: null, type: 'label_renamed', source: actor, field: 'name', from: name, to: newName, meta: { label: newName } })
647
+ }
648
+ if (typeof a.color === 'string') {
649
+ const beforeColor = label.color
650
+ label.color = normColor(a.color) || label.color
651
+ if (label.color !== beforeColor) {
652
+ record(board, { cardId: null, type: 'label_color_changed', source: actor, field: 'color', from: beforeColor, to: label.color, meta: { label: label.name } })
274
653
  }
275
654
  }
276
- if (typeof a.color === 'string') label.color = normColor(a.color) || label.color
277
655
  await save(wsid)
278
656
  return result({ message: 'Label updated' })
279
657
  }
@@ -284,8 +662,12 @@ export function apply(ctx) {
284
662
  if (idx < 0) return result({ error: 'Label not found' })
285
663
  board.labels.splice(idx, 1)
286
664
  for (const card of board.cards) {
287
- if (card.label === name) card.label = undefined
665
+ if (card.label === name) {
666
+ card.label = undefined
667
+ record(board, { cardId: card.id, type: 'card_label_changed', source: actor, field: 'label', from: name, to: null, meta: { title: card.title } })
668
+ }
288
669
  }
670
+ record(board, { cardId: null, type: 'label_deleted', source: actor, meta: { label: name } })
289
671
  await save(wsid)
290
672
  return result({ message: 'Label deleted' })
291
673
  }
@@ -315,8 +697,13 @@ export function apply(ctx) {
315
697
 
316
698
  const runTool = async (method, args, exec) => {
317
699
  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) }
700
+ const r = await dispatch(wsid, method, args, 'agent')
701
+ return {
702
+ ok: !r.error,
703
+ message: r.error || r.message || 'Done',
704
+ board: summaryOfClone(r.board),
705
+ warnings: Array.isArray(r.warnings) ? r.warnings : [],
706
+ }
320
707
  }
321
708
 
322
709
  // ---- 浏览器数据层:经官方 webServer 扩展点注册 /api/kanban ----
@@ -329,7 +716,7 @@ export function apply(ctx) {
329
716
  const method = typeof body.method === 'string' ? body.method : 'get'
330
717
  const args = body.args || {}
331
718
  const wsid = typeof args.workspaceId === 'string' && args.workspaceId ? args.workspaceId : 'default'
332
- const result = await dispatch(wsid, method, args)
719
+ const result = await dispatch(wsid, method, args, 'human')
333
720
  res.writeHead(200, { 'content-type': 'application/json' })
334
721
  res.end(JSON.stringify(result))
335
722
  } catch (err) {
@@ -358,6 +745,7 @@ export function apply(ctx) {
358
745
  routeState.timer = timer.interval(() => {
359
746
  routeState.attempts++
360
747
  registerRoute()
748
+ maybeStartupCheck()
361
749
  if (routeState.registered || routeState.attempts >= 40) {
362
750
  if (routeState.timer) routeState.timer()
363
751
  }
@@ -365,6 +753,76 @@ export function apply(ctx) {
365
753
  }
366
754
  }
367
755
 
756
+ // ---- 启动校验:全量体检看板文件(只读 + 备份损坏文件,不迁移、不加载内存)----
757
+ const startupState = { done: false }
758
+ const maybeStartupCheck = () => {
759
+ if (startupState.done) return
760
+ if (!getFs()) return
761
+ startupState.done = true
762
+ runStartupCheck()
763
+ }
764
+ const runStartupCheck = async () => {
765
+ const fs = getFs()
766
+ const wsRoot = root()
767
+ if (!fs || !wsRoot) return
768
+ try {
769
+ const dir = await fs.resolve('.', { cwd: wsRoot })
770
+ const entries = await fs.listDir(dir)
771
+ const boardFiles = entries.filter(
772
+ (e) => typeof e.name === 'string' && /^kanban-board-.+\.json$/.test(e.name),
773
+ )
774
+ if (boardFiles.length === 0) {
775
+ console.log('dsh-kanban: 启动校验完成,未发现看板数据文件')
776
+ return
777
+ }
778
+ let corrupt = 0
779
+ let pendingUpgrade = 0
780
+ let unsupported = 0
781
+ let ok = 0
782
+ for (const entry of boardFiles) {
783
+ const wsid = entry.name.slice('kanban-board-'.length, -'.json'.length)
784
+ try {
785
+ const text = await fs.readText(entry.target)
786
+ const parsed = parseBoardText(text)
787
+ if (parsed.ok) {
788
+ if (parsed.migrated) {
789
+ pendingUpgrade++
790
+ console.log('dsh-kanban: 启动校验 ' + wsid + ':schemaVersion ' + parsed.fromVersion + ',首次打开时将自动升级')
791
+ } else {
792
+ ok++
793
+ }
794
+ } else if (parsed.kind === 'unsupported') {
795
+ unsupported++
796
+ console.log('dsh-kanban: 启动校验 ' + wsid + ':文件由更新版本插件写入(schemaVersion ' + parsed.version + ')')
797
+ } else {
798
+ corrupt++
799
+ const suffix = 'corrupt-' + timestamp()
800
+ const backupTarget = await fs.resolve(entry.name + '.' + suffix, { cwd: wsRoot })
801
+ await fs.writeText(backupTarget, text)
802
+ console.log('dsh-kanban: 启动校验 ' + wsid + ':数据文件损坏(' + parsed.kind + '),已备份为 ' + entry.name + '.' + suffix)
803
+ }
804
+ } catch (err) {
805
+ console.log('dsh-kanban: 启动校验 ' + wsid + ':检查失败 ' + ((err && err.message) || err))
806
+ }
807
+ }
808
+ console.log(
809
+ 'dsh-kanban: 启动校验完成:共 ' +
810
+ boardFiles.length +
811
+ ' 个看板文件,正常 ' +
812
+ ok +
813
+ ',损坏并已备份 ' +
814
+ corrupt +
815
+ ',待自动升级 ' +
816
+ pendingUpgrade +
817
+ ',版本超前 ' +
818
+ unsupported,
819
+ )
820
+ } catch (err) {
821
+ console.log('dsh-kanban: 启动校验失败:' + ((err && err.message) || err))
822
+ }
823
+ }
824
+ maybeStartupCheck()
825
+
368
826
  // ---- 工具注册 ----
369
827
  const resultSchema = {
370
828
  type: 'object',
@@ -372,13 +830,18 @@ export function apply(ctx) {
372
830
  ok: { type: 'boolean' },
373
831
  message: { type: 'string' },
374
832
  board: { type: 'object' },
833
+ warnings: { type: 'array', items: { type: 'string' } },
375
834
  },
376
835
  required: ['ok', 'message'],
377
836
  additionalProperties: false,
378
837
  }
379
838
  const renderBoard = (value) => {
380
839
  const b = value && value.board
381
- const lines = [String((value && value.message) || '')]
840
+ const lines = []
841
+ if (Array.isArray(value && value.warnings)) {
842
+ for (const w of value.warnings) lines.push('⚠ ' + w)
843
+ }
844
+ lines.push(String((value && value.message) || ''))
382
845
  if (b && Array.isArray(b.columns)) {
383
846
  lines.push('Board state:')
384
847
  for (const col of b.columns) {
@@ -412,6 +875,51 @@ export function apply(ctx) {
412
875
  return runTool('get', args, exec)
413
876
  },
414
877
  },
878
+ {
879
+ name: 'kanban_get_card',
880
+ description: "Read one card's full details (title, note, label, priority) by id from the current project (workspace) board. Use kanban_get first to discover card ids, then this tool to read a card's complete note and fields.",
881
+ parameters: {
882
+ type: 'object',
883
+ properties: {
884
+ id: { type: 'string', description: 'Card id (see kanban_get output)' },
885
+ },
886
+ required: ['id'],
887
+ },
888
+ output: {
889
+ schema: {
890
+ type: 'object',
891
+ properties: {
892
+ ok: { type: 'boolean' },
893
+ message: { type: 'string' },
894
+ card: { type: 'object' },
895
+ warnings: { type: 'array', items: { type: 'string' } },
896
+ },
897
+ required: ['ok', 'message'],
898
+ additionalProperties: false,
899
+ },
900
+ render: (args, value) => {
901
+ const c = value && value.card
902
+ const lines = []
903
+ if (Array.isArray(value && value.warnings)) {
904
+ for (const w of value.warnings) lines.push('⚠ ' + w)
905
+ }
906
+ lines.push(String((value && value.message) || ''))
907
+ if (c) {
908
+ lines.push('[' + c.id + '] ' + c.title)
909
+ if (c.priority) lines.push('Priority: ' + c.priority)
910
+ if (c.label) lines.push('Label: ' + c.label)
911
+ if (c.note) lines.push('Note: ' + c.note)
912
+ }
913
+ return [{ type: 'text', text: lines.join('\n') }]
914
+ },
915
+ },
916
+ async execute(args, exec) {
917
+ const wsid = await wsidOfExec(exec)
918
+ const r = await dispatch(wsid, 'getCard', args, 'agent')
919
+ if (r.error) return { ok: false, message: r.error, warnings: Array.isArray(r.warnings) ? r.warnings : [] }
920
+ return { ok: true, message: 'Card ' + String(args.id || '') + ' details', card: r.card, warnings: Array.isArray(r.warnings) ? r.warnings : [] }
921
+ },
922
+ },
415
923
  {
416
924
  name: 'kanban_add_card',
417
925
  description: 'Add a card to the current project (workspace) kanban board. Write feature breakdowns and plans to the board: one card per task step.',
@@ -595,13 +1103,18 @@ export function apply(ctx) {
595
1103
  ok: { type: 'boolean' },
596
1104
  message: { type: 'string' },
597
1105
  labels: { type: 'array', items: { type: 'object' } },
1106
+ warnings: { type: 'array', items: { type: 'string' } },
598
1107
  },
599
1108
  required: ['ok', 'message'],
600
1109
  additionalProperties: false,
601
1110
  },
602
1111
  render: (args, value) => {
603
1112
  const labels = value && value.labels
604
- const lines = [String((value && value.message) || '')]
1113
+ const lines = []
1114
+ if (Array.isArray(value && value.warnings)) {
1115
+ for (const w of value.warnings) lines.push('⚠ ' + w)
1116
+ }
1117
+ lines.push(String((value && value.message) || ''))
605
1118
  if (Array.isArray(labels)) {
606
1119
  for (const l of labels) lines.push('- ' + l.name + ' (' + l.color + ')')
607
1120
  }
@@ -610,9 +1123,14 @@ export function apply(ctx) {
610
1123
  },
611
1124
  async execute(args, exec) {
612
1125
  const wsid = await wsidOfExec(exec)
613
- const r = await dispatch(wsid, 'get', args)
1126
+ const r = await dispatch(wsid, 'get', args, 'agent')
614
1127
  const labels = (r.board && r.board.labels) || []
615
- return { ok: true, message: 'Labels (workspace ' + wsid + ')', labels }
1128
+ return {
1129
+ ok: true,
1130
+ message: 'Labels (workspace ' + wsid + ')',
1131
+ labels,
1132
+ warnings: Array.isArray(r.warnings) ? r.warnings : [],
1133
+ }
616
1134
  },
617
1135
  },
618
1136
  ]