@alpacachen/dsh-kanban 1.3.0 → 1.3.2

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.
Files changed (5) hide show
  1. package/README.md +53 -119
  2. package/README.zh.md +48 -114
  3. package/index.js +136 -92
  4. package/lib/client.js +482 -12
  5. package/package.json +9 -3
package/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * 职责:
8
8
  * - 按工作区(项目)隔离:boards 以 workspaceId 为键,每个工作区一块独立看板
9
- * - 磁盘持久化:经 ctx.fs 写入 <workspaceRoot>/kanban-board-<workspaceId>.json
9
+ * - 磁盘持久化:经 ctx.fs 写入 <workspace.path>/.dsh-kanban.json
10
10
  * - 模型工具:经 ctx.tools.register 注册 14 个 kanban_* 工具
11
11
  * - 浏览器数据层:经 ctx.get('webServer') 注册 /api/kanban 前缀路由
12
12
  *
@@ -275,41 +275,53 @@ export function apply(ctx) {
275
275
  ]
276
276
 
277
277
  // ---- 持久化定位 ----
278
- const root = () => {
279
- const p = getPolicy()
280
- return p && typeof p.workspaceRoot === 'string' ? p.workspaceRoot : undefined
278
+ const BOARD_FILE = '.dsh-kanban.json'
279
+ const workspaceKey = (workspace) => String(workspace.id || workspace.path)
280
+ const writePolicyFor = (workspace, session) => {
281
+ const policy = getPolicy()
282
+ const resolved =
283
+ policy && typeof policy.resolve === 'function'
284
+ ? policy.resolve(session ? { session } : {})
285
+ : { mode: (policy && policy.defaultMode) || 'workspace-write' }
286
+ return { ...resolved, workspaceRoot: workspace.path }
281
287
  }
282
- const fileName = (wsid) => 'kanban-board-' + wsid + '.json'
283
- const resolveFile = async (wsid) => {
288
+ const resolveFile = async (workspace) => {
284
289
  const fs = getFs()
285
290
  if (!fs) return null
286
291
  try {
287
- return await fs.resolve(fileName(wsid), root() ? { cwd: root() } : {})
292
+ return await fs.resolve(BOARD_FILE, { cwd: workspace.path })
288
293
  } catch (err) {
289
- console.log('dsh-kanban: 解析看板文件失败,退回内存模式:' + ((err && err.message) || err))
294
+ console.log(
295
+ 'dsh-kanban: 解析工作区看板文件失败 ' + workspaceKey(workspace) + ':' + ((err && err.message) || err),
296
+ )
290
297
  return null
291
298
  }
292
299
  }
293
- const targetOf = async (wsid) => {
294
- if (!fileTargets.has(wsid)) fileTargets.set(wsid, await resolveFile(wsid))
295
- return fileTargets.get(wsid)
300
+ const targetOf = async (workspace) => {
301
+ const key = workspaceKey(workspace)
302
+ if (!fileTargets.has(key)) fileTargets.set(key, await resolveFile(workspace))
303
+ return fileTargets.get(key)
304
+ }
305
+ const persistedFlag = (workspace) => {
306
+ const key = workspaceKey(workspace)
307
+ return fileTargets.has(key) && fileTargets.get(key) !== null
296
308
  }
297
- const persistedFlag = (wsid) => fileTargets.has(wsid) && fileTargets.get(wsid) !== null
298
309
 
299
310
  // ---- 看板读写 ----
300
311
 
301
- // 备份原文件为 <文件名>.<suffix>(复制而非移动,保证原文件在写回前始终存在)
302
- const backupFile = async (wsid, suffix) => {
312
+ // 备份原文件为 .dsh-kanban.json.<suffix>(复制而非移动,保证原文件在写回前始终存在)
313
+ const backupFile = async (workspace, suffix, session) => {
303
314
  const fs = getFs()
304
- const target = await targetOf(wsid)
315
+ const target = await targetOf(workspace)
305
316
  if (!fs || !target) return null
317
+ const key = workspaceKey(workspace)
306
318
  try {
307
319
  const text = await fs.readText(target)
308
- const backupTarget = await fs.resolve(fileName(wsid) + '.' + suffix, root() ? { cwd: root() } : {})
309
- await fs.writeText(backupTarget, text)
320
+ const backupTarget = await fs.resolve(BOARD_FILE + '.' + suffix, { cwd: workspace.path })
321
+ await fs.writeText(backupTarget, text, undefined, undefined, writePolicyFor(workspace, session))
310
322
  return backupTarget
311
323
  } catch (err) {
312
- console.log('dsh-kanban: 备份失败 ' + wsid + ' (' + suffix + '):' + ((err && err.message) || err))
324
+ console.log('dsh-kanban: 备份失败 ' + key + ' (' + suffix + '):' + ((err && err.message) || err))
313
325
  return null
314
326
  }
315
327
  }
@@ -326,14 +338,15 @@ export function apply(ctx) {
326
338
  }
327
339
  const timestamp = () => new Date().toISOString().replace(/[:.]/g, '-')
328
340
 
329
- // 首次访问某工作区时从磁盘加载;异常数据一律不阻塞看板可用性
330
- const boardOf = async (wsid) => {
331
- let board = boards.get(wsid)
341
+ // 首次访问某工作区时从该工作区根目录加载;异常数据一律不阻塞看板可用性
342
+ const boardOf = async (workspace, session) => {
343
+ const key = workspaceKey(workspace)
344
+ let board = boards.get(key)
332
345
  if (board) return board
333
346
  board = { schemaVersion: SCHEMA_VERSION, columns: [], labels: [], cards: [], activities: [], warnings: [] }
334
- boards.set(wsid, board)
347
+ boards.set(key, board)
335
348
  const fs = getFs()
336
- const target = await targetOf(wsid)
349
+ const target = await targetOf(workspace)
337
350
  let migrated = false
338
351
  if (fs && target) {
339
352
  try {
@@ -346,19 +359,19 @@ export function apply(ctx) {
346
359
  board.cards = parsed.data.cards
347
360
  board.activities = Array.isArray(parsed.data.activities) ? parsed.data.activities : []
348
361
  migrated = parsed.migrated
349
- // 迁移场景:写回前先把迁移前的原文件备份下来,保证可回滚
362
+ // schema 迁移场景:写回前先把迁移前的原文件备份下来,保证可回滚
350
363
  if (parsed.migrated) {
351
- await backupFile(wsid, 'bak-v' + parsed.fromVersion)
364
+ await backupFile(workspace, 'bak-v' + parsed.fromVersion, session)
352
365
  }
353
366
  } else {
354
367
  // 损坏 / 结构无效 / 版本超前:原文件已无法安全读取,先备份再以空板继续
355
368
  const suffix =
356
369
  parsed.kind === 'unsupported' ? 'unsupported-v' + parsed.version : 'corrupt-' + timestamp()
357
- await backupFile(wsid, suffix)
370
+ await backupFile(workspace, suffix, session)
358
371
  }
359
372
  } catch (err) {
360
373
  // 尚无看板文件(首次使用)或 fs 读取异常,保留默认空板
361
- console.log('dsh-kanban: 读取看板失败 ' + wsid + ':' + ((err && err.message) || err))
374
+ console.log('dsh-kanban: 读取看板失败 ' + key + ':' + ((err && err.message) || err))
362
375
  }
363
376
  }
364
377
  for (const col of board.columns) bumpSeq(col.id)
@@ -370,14 +383,15 @@ export function apply(ctx) {
370
383
  if (board.labels.length === 0) {
371
384
  board.labels = DEFAULT_LABELS.map((l) => ({ ...l }))
372
385
  }
373
- // 升级写回:迁移成功即落盘新版本,保证每个文件只迁移一次
374
- if (migrated && fs && target) await save(wsid)
386
+ // schema 升级写回:迁移成功即落盘新版本,保证每个文件只迁移一次
387
+ if (migrated && fs && target) await save(workspace, session)
375
388
  return board
376
389
  }
377
- const save = async (wsid) => {
390
+ const save = async (workspace, session) => {
378
391
  const fs = getFs()
379
- const target = await targetOf(wsid)
380
- const board = boards.get(wsid)
392
+ const target = await targetOf(workspace)
393
+ const key = workspaceKey(workspace)
394
+ const board = boards.get(key)
381
395
  if (!fs || !target || !board) return
382
396
  try {
383
397
  await fs.writeText(
@@ -389,9 +403,12 @@ export function apply(ctx) {
389
403
  cards: board.cards,
390
404
  activities: Array.isArray(board.activities) ? board.activities : [],
391
405
  }),
406
+ undefined,
407
+ undefined,
408
+ writePolicyFor(workspace, session),
392
409
  )
393
410
  } catch (err) {
394
- console.log('dsh-kanban: 保存失败 ' + wsid + ':' + ((err && err.message) || err))
411
+ console.log('dsh-kanban: 保存失败 ' + key + ':' + ((err && err.message) || err))
395
412
  }
396
413
  }
397
414
 
@@ -449,11 +466,11 @@ export function apply(ctx) {
449
466
  }
450
467
 
451
468
  // ---- 核心数据操作:工具与浏览器 HTTP 共用同一份逻辑 ----
452
- const dispatch = async (wsid, method, args, source) => {
453
- const board = await boardOf(wsid)
469
+ const dispatch = async (workspace, method, args, source, session) => {
470
+ const board = await boardOf(workspace, session)
454
471
  const a = args || {}
455
472
  const actor = source === 'agent' ? 'agent' : 'human'
456
- const persisted = () => persistedFlag(wsid)
473
+ const persisted = () => persistedFlag(workspace)
457
474
  const result = (extra) => ({ board: cloneBoard(board), persisted: persisted(), warnings: takeWarnings(board), ...extra })
458
475
 
459
476
  switch (method) {
@@ -491,7 +508,7 @@ export function apply(ctx) {
491
508
  priority: card.priority ?? null,
492
509
  },
493
510
  })
494
- await save(wsid)
511
+ await save(workspace, session)
495
512
  return result({ message: 'Card added to "' + col.title + '"' })
496
513
  }
497
514
 
@@ -526,7 +543,7 @@ export function apply(ctx) {
526
543
  if (after.priority !== before.priority) {
527
544
  record(board, { cardId: card.id, type: 'card_priority_changed', source: actor, field: 'priority', from: before.priority, to: after.priority })
528
545
  }
529
- await save(wsid)
546
+ await save(workspace, session)
530
547
  }
531
548
  return card
532
549
  ? result({ message: 'Card updated' })
@@ -540,7 +557,7 @@ export function apply(ctx) {
540
557
  if (card) {
541
558
  record(board, { cardId: id, type: 'card_deleted', source: actor, meta: { title: card.title } })
542
559
  }
543
- await save(wsid)
560
+ await save(workspace, session)
544
561
  return result({ message: 'Card deleted' })
545
562
  }
546
563
 
@@ -562,7 +579,7 @@ export function apply(ctx) {
562
579
  if (fromTitle !== target.title) {
563
580
  record(board, { cardId: card.id, type: 'card_moved', source: actor, field: 'columnId', from: fromTitle, to: target.title, meta: { title: card.title } })
564
581
  }
565
- await save(wsid)
582
+ await save(workspace, session)
566
583
  return result({ message: 'Moved to "' + target.title + '"' })
567
584
  }
568
585
 
@@ -570,7 +587,7 @@ export function apply(ctx) {
570
587
  const title = str(a.title, '').slice(0, 40) || 'New list'
571
588
  board.columns.push({ id: nextId('c'), title })
572
589
  record(board, { cardId: null, type: 'column_added', source: actor, meta: { column: title } })
573
- await save(wsid)
590
+ await save(workspace, session)
574
591
  return result({ message: 'List added: "' + title + '"' })
575
592
  }
576
593
 
@@ -582,7 +599,7 @@ export function apply(ctx) {
582
599
  if (col.title !== before) {
583
600
  record(board, { cardId: null, type: 'column_renamed', source: actor, field: 'title', from: before, to: col.title, meta: { column: col.title } })
584
601
  }
585
- await save(wsid)
602
+ await save(workspace, session)
586
603
  }
587
604
  return col ? result({ message: 'List renamed' }) : result({ error: 'List not found' })
588
605
  }
@@ -602,7 +619,7 @@ export function apply(ctx) {
602
619
  }
603
620
  }
604
621
  record(board, { cardId: null, type: 'column_deleted', source: actor, meta: { column: deleted.title } })
605
- await save(wsid)
622
+ await save(workspace, session)
606
623
  return result({ message: 'List deleted, cards moved to "' + board.columns[0].title + '"' })
607
624
  }
608
625
 
@@ -615,7 +632,7 @@ export function apply(ctx) {
615
632
  ? Math.max(0, Math.min(Math.floor(a.toIndex), board.columns.length))
616
633
  : board.columns.length
617
634
  board.columns.splice(toIndex, 0, col)
618
- await save(wsid)
635
+ await save(workspace, session)
619
636
  return result({ message: 'List order updated' })
620
637
  }
621
638
 
@@ -625,7 +642,7 @@ export function apply(ctx) {
625
642
  if (findLabel(board, name)) return result({ error: 'Label already exists' })
626
643
  board.labels.push({ name, color: normColor(a.color) || '#94a3b8' })
627
644
  record(board, { cardId: null, type: 'label_added', source: actor, meta: { label: name } })
628
- await save(wsid)
645
+ await save(workspace, session)
629
646
  return result({ message: 'Label added: "' + name + '"' })
630
647
  }
631
648
 
@@ -652,7 +669,7 @@ export function apply(ctx) {
652
669
  record(board, { cardId: null, type: 'label_color_changed', source: actor, field: 'color', from: beforeColor, to: label.color, meta: { label: label.name } })
653
670
  }
654
671
  }
655
- await save(wsid)
672
+ await save(workspace, session)
656
673
  return result({ message: 'Label updated' })
657
674
  }
658
675
 
@@ -668,7 +685,7 @@ export function apply(ctx) {
668
685
  }
669
686
  }
670
687
  record(board, { cardId: null, type: 'label_deleted', source: actor, meta: { label: name } })
671
- await save(wsid)
688
+ await save(workspace, session)
672
689
  return result({ message: 'Label deleted' })
673
690
  }
674
691
 
@@ -677,27 +694,42 @@ export function apply(ctx) {
677
694
  }
678
695
  }
679
696
 
680
- // ---- 工具执行上下文 -> 工作区 id ----
681
- const wsidOfExec = async (exec) => {
697
+ // ---- 工具执行上下文 / 浏览器 workspaceId -> 工作区 ----
698
+ const workspaceOfExec = async (exec) => {
682
699
  const agent = exec && exec.agent
683
- const cwd = agent && agent.session && agent.session.header && agent.session.header.cwd
684
- if (typeof cwd === 'string' && cwd) {
685
- const registry = getWorkspaceRegistry()
686
- if (registry) {
687
- try {
688
- const ws = await registry.resolveByPath(cwd)
689
- if (ws) return ws.id
690
- } catch (err) {
691
- console.log('dsh-kanban: 解析工作区失败:' + ((err && err.message) || err))
692
- }
700
+ const session = agent && agent.session
701
+ const cwd = session && session.header && session.header.cwd
702
+ if (typeof cwd !== 'string' || !cwd) return null
703
+ const registry = getWorkspaceRegistry()
704
+ if (registry) {
705
+ try {
706
+ const workspace = await registry.resolveByPath(cwd)
707
+ if (workspace) return workspace
708
+ } catch (err) {
709
+ console.log('dsh-kanban: 解析工作区失败:' + ((err && err.message) || err))
693
710
  }
694
711
  }
695
- return 'default'
712
+ // 未登记到 WorkspaceRegistry 的会话仍以自身 cwd 作为工作区根目录。
713
+ return { id: 'cwd:' + cwd, path: cwd, title: cwd }
714
+ }
715
+ const workspaceOfId = (id) => {
716
+ const registry = getWorkspaceRegistry()
717
+ return registry && typeof registry.get === 'function' ? registry.get(id) : undefined
696
718
  }
719
+ const emptyBoardSummary = { columns: [], labels: [], cards: [] }
697
720
 
698
721
  const runTool = async (method, args, exec) => {
699
- const wsid = await wsidOfExec(exec)
700
- const r = await dispatch(wsid, method, args, 'agent')
722
+ const workspace = await workspaceOfExec(exec)
723
+ if (!workspace) {
724
+ return {
725
+ ok: false,
726
+ message: 'Cannot determine the current workspace from the tool execution context',
727
+ board: emptyBoardSummary,
728
+ warnings: [],
729
+ }
730
+ }
731
+ const session = exec && exec.agent && exec.agent.session
732
+ const r = await dispatch(workspace, method, args, 'agent', session)
701
733
  return {
702
734
  ok: !r.error,
703
735
  message: r.error || r.message || 'Done',
@@ -715,8 +747,14 @@ export function apply(ctx) {
715
747
  const body = raw ? JSON.parse(raw) : {}
716
748
  const method = typeof body.method === 'string' ? body.method : 'get'
717
749
  const args = body.args || {}
718
- const wsid = typeof args.workspaceId === 'string' && args.workspaceId ? args.workspaceId : 'default'
719
- const result = await dispatch(wsid, method, args, 'human')
750
+ const workspaceId = typeof args.workspaceId === 'string' ? args.workspaceId : ''
751
+ const workspace = workspaceId ? workspaceOfId(workspaceId) : undefined
752
+ if (!workspace) {
753
+ res.writeHead(400, { 'content-type': 'application/json' })
754
+ res.end(JSON.stringify({ error: 'Unknown workspace: ' + (workspaceId || '(missing)') }))
755
+ return
756
+ }
757
+ const result = await dispatch(workspace, method, args, 'human')
720
758
  res.writeHead(200, { 'content-type': 'application/json' })
721
759
  res.end(JSON.stringify(result))
722
760
  } catch (err) {
@@ -753,61 +791,63 @@ export function apply(ctx) {
753
791
  }
754
792
  }
755
793
 
756
- // ---- 启动校验:全量体检看板文件(只读 + 备份损坏文件,不迁移、不加载内存)----
794
+ // ---- 启动校验:逐个工作区体检 .dsh-kanban.json(只读 + 备份损坏文件)----
757
795
  const startupState = { done: false }
758
796
  const maybeStartupCheck = () => {
759
797
  if (startupState.done) return
760
- if (!getFs()) return
798
+ const registry = getWorkspaceRegistry()
799
+ if (!getFs() || !registry || typeof registry.list !== 'function') return
761
800
  startupState.done = true
762
801
  runStartupCheck()
763
802
  }
764
803
  const runStartupCheck = async () => {
765
804
  const fs = getFs()
766
- const wsRoot = root()
767
- if (!fs || !wsRoot) return
805
+ const registry = getWorkspaceRegistry()
806
+ if (!fs || !registry || typeof registry.list !== 'function') return
768
807
  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
- }
808
+ const workspaces = registry.list()
809
+ let found = 0
778
810
  let corrupt = 0
779
811
  let pendingUpgrade = 0
780
812
  let unsupported = 0
781
813
  let ok = 0
782
- for (const entry of boardFiles) {
783
- const wsid = entry.name.slice('kanban-board-'.length, -'.json'.length)
814
+ for (const workspace of workspaces) {
815
+ const key = workspaceKey(workspace)
784
816
  try {
785
- const text = await fs.readText(entry.target)
817
+ const target = await fs.resolve(BOARD_FILE, { cwd: workspace.path })
818
+ const text = await fs.readText(target)
819
+ found++
786
820
  const parsed = parseBoardText(text)
787
821
  if (parsed.ok) {
788
822
  if (parsed.migrated) {
789
823
  pendingUpgrade++
790
- console.log('dsh-kanban: 启动校验 ' + wsid + ':schemaVersion ' + parsed.fromVersion + ',首次打开时将自动升级')
824
+ console.log('dsh-kanban: 启动校验 ' + key + ':schemaVersion ' + parsed.fromVersion + ',首次打开时将自动升级')
791
825
  } else {
792
826
  ok++
793
827
  }
794
828
  } else if (parsed.kind === 'unsupported') {
795
829
  unsupported++
796
- console.log('dsh-kanban: 启动校验 ' + wsid + ':文件由更新版本插件写入(schemaVersion ' + parsed.version + ')')
830
+ console.log('dsh-kanban: 启动校验 ' + key + ':文件由更新版本插件写入(schemaVersion ' + parsed.version + ')')
797
831
  } else {
798
832
  corrupt++
799
833
  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)
834
+ const backupTarget = await fs.resolve(BOARD_FILE + '.' + suffix, { cwd: workspace.path })
835
+ await fs.writeText(backupTarget, text, undefined, undefined, writePolicyFor(workspace))
836
+ console.log('dsh-kanban: 启动校验 ' + key + ':数据文件损坏(' + parsed.kind + '),已备份为 ' + BOARD_FILE + '.' + suffix)
803
837
  }
804
838
  } catch (err) {
805
- console.log('dsh-kanban: 启动校验 ' + wsid + ':检查失败 ' + ((err && err.message) || err))
839
+ if (!err || (err.code !== 'ENOENT' && err.message !== 'ENOENT')) {
840
+ console.log('dsh-kanban: 启动校验 ' + key + ':检查失败 ' + ((err && err.message) || err))
841
+ }
806
842
  }
807
843
  }
844
+ if (found === 0) {
845
+ console.log('dsh-kanban: 启动校验完成,未发现看板数据文件')
846
+ return
847
+ }
808
848
  console.log(
809
849
  'dsh-kanban: 启动校验完成:共 ' +
810
- boardFiles.length +
850
+ found +
811
851
  ' 个看板文件,正常 ' +
812
852
  ok +
813
853
  ',损坏并已备份 ' +
@@ -914,8 +954,10 @@ export function apply(ctx) {
914
954
  },
915
955
  },
916
956
  async execute(args, exec) {
917
- const wsid = await wsidOfExec(exec)
918
- const r = await dispatch(wsid, 'getCard', args, 'agent')
957
+ const workspace = await workspaceOfExec(exec)
958
+ if (!workspace) return { ok: false, message: 'Cannot determine the current workspace from the tool execution context', warnings: [] }
959
+ const session = exec && exec.agent && exec.agent.session
960
+ const r = await dispatch(workspace, 'getCard', args, 'agent', session)
919
961
  if (r.error) return { ok: false, message: r.error, warnings: Array.isArray(r.warnings) ? r.warnings : [] }
920
962
  return { ok: true, message: 'Card ' + String(args.id || '') + ' details', card: r.card, warnings: Array.isArray(r.warnings) ? r.warnings : [] }
921
963
  },
@@ -1122,12 +1164,14 @@ export function apply(ctx) {
1122
1164
  },
1123
1165
  },
1124
1166
  async execute(args, exec) {
1125
- const wsid = await wsidOfExec(exec)
1126
- const r = await dispatch(wsid, 'get', args, 'agent')
1167
+ const workspace = await workspaceOfExec(exec)
1168
+ if (!workspace) return { ok: false, message: 'Cannot determine the current workspace from the tool execution context', labels: [], warnings: [] }
1169
+ const session = exec && exec.agent && exec.agent.session
1170
+ const r = await dispatch(workspace, 'get', args, 'agent', session)
1127
1171
  const labels = (r.board && r.board.labels) || []
1128
1172
  return {
1129
1173
  ok: true,
1130
- message: 'Labels (workspace ' + wsid + ')',
1174
+ message: 'Labels (workspace ' + workspaceKey(workspace) + ')',
1131
1175
  labels,
1132
1176
  warnings: Array.isArray(r.warnings) ? r.warnings : [],
1133
1177
  }