dsh-taskboard 0.6.5 → 0.6.7

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 (47) hide show
  1. package/README.md +301 -282
  2. package/lib/client.js +937 -920
  3. package/lib/host/archive-sessions.js +30 -0
  4. package/lib/host/archive-sessions.js.map +1 -0
  5. package/lib/host/execution.js +3 -0
  6. package/lib/host/execution.js.map +1 -1
  7. package/lib/host/locale.js +17 -0
  8. package/lib/host/locale.js.map +1 -0
  9. package/lib/host/routes.js +45 -6
  10. package/lib/host/routes.js.map +1 -1
  11. package/lib/host/scheduler.js +2 -0
  12. package/lib/host/scheduler.js.map +1 -1
  13. package/lib/host/session-sync.js +4 -1
  14. package/lib/host/session-sync.js.map +1 -1
  15. package/lib/host/templates.js +11 -75
  16. package/lib/host/templates.js.map +1 -1
  17. package/lib/host/tools.js +19 -6
  18. package/lib/host/tools.js.map +1 -1
  19. package/lib/shared/api.js.map +1 -1
  20. package/lib/shared/builtin-templates.js +155 -0
  21. package/lib/shared/builtin-templates.js.map +1 -0
  22. package/lib/shared/protocol.js +39 -2
  23. package/lib/shared/protocol.js.map +1 -1
  24. package/package.json +90 -90
  25. package/src/client/api.ts +5 -1
  26. package/src/client/board/TaskBoard.tsx +16 -11
  27. package/src/client/board/TaskDetail.tsx +116 -10
  28. package/src/client/board/TemplateManager.tsx +22 -10
  29. package/src/client/board-mount.tsx +4 -0
  30. package/src/client/controller.ts +23 -3
  31. package/src/client/i18n/en.ts +18 -0
  32. package/src/client/i18n/templates.ts +25 -0
  33. package/src/client/i18n/zh.ts +18 -0
  34. package/src/client/styles.ts +14 -3
  35. package/src/client/window-inset.ts +38 -0
  36. package/src/host/archive-sessions.ts +18 -0
  37. package/src/host/execution.ts +4 -1
  38. package/src/host/locale.ts +44 -0
  39. package/src/host/routes.ts +40 -7
  40. package/src/host/scheduler.ts +2 -0
  41. package/src/host/session-sync.ts +4 -1
  42. package/src/host/templates.ts +8 -59
  43. package/src/host/tools.ts +26 -9
  44. package/src/shared/api.ts +6 -3
  45. package/src/shared/builtin-templates.ts +153 -0
  46. package/src/shared/protocol.ts +64 -0
  47. package/src/shared/version.ts +9 -9
@@ -1,3 +1,4 @@
1
+ import { archiveTaskSessions } from './archive-sessions.ts'
1
2
  /**
2
3
  * /dsh-taskboard routes on the shared DSH webserver: a JSON API for the
3
4
  * GUI's human operations (create/update/move/comment/delete — actor `user`,
@@ -40,10 +41,12 @@ import {
40
41
  type TaskLedger,
41
42
  type TaskModel,
42
43
  type TaskRecord,
44
+ type SystemCommentRow,
43
45
  } from '../shared/protocol.ts'
44
46
  import { WORKTREE_DIR, worktreePathOf, type GitFace } from './git.ts'
45
47
  import { removeMirror, repoMainPath } from './isolation.ts'
46
48
  import { createRepoScanner, type RepoScanner } from './repos.ts'
49
+ import { activeHostLocale } from './locale.ts'
47
50
  import type { CatalogModelItem, CatalogPresetItem, MergeRepoResult, TaskTemplate } from '../shared/api.ts'
48
51
  import type { TemplateStore } from './templates.ts'
49
52
  import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
@@ -316,10 +319,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
316
319
  } catch { /* fail-soft → false */ }
317
320
  // gitignore 建议 (plan §3.2): suggest (never write) ignoring our
318
321
  // worktree directory, once per workspace per host run. Root repos only.
322
+ // The line is localized from the DSH locale preference (see host/locale.ts).
319
323
  if (rootRepo && !gitHinted.has(path)) {
320
324
  gitHinted.add(path)
321
325
  if (await gitignoreMissing(path)) {
322
- console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`)
326
+ const file = `${path}/.gitignore`
327
+ const hint = activeHostLocale(ctx) === 'zh'
328
+ ? `建议在 ${file} 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`
329
+ : `suggests adding one line to ${file}: ${WORKTREE_DIR}/ to hide the task worktree directory (no automatic edits)`
330
+ console.info(`[dsh-taskboard] ${hint}`)
323
331
  }
324
332
  }
325
333
  // The nested scan always runs: repoCount needs it even when the root
@@ -372,7 +380,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
372
380
  if (req.method === 'GET') {
373
381
  if (pathname === `${ROUTE_PREFIX}/state`) {
374
382
  await store.load()
375
- json(res, { ok: true, value: store.snapshot() })
383
+ json(res, { ok: true, value: { ...store.snapshot(), capabilities: { archiveSessions: typeof workspaces.archiveSession === 'function' } } })
376
384
  return
377
385
  }
378
386
  if (pathname === `${ROUTE_PREFIX}/workspaces`) {
@@ -615,6 +623,12 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
615
623
  try {
616
624
  const task = store.get(id)
617
625
  if (task === undefined) throw new Error('Error: not_found: no such task')
626
+ if (action === 'archive-sessions') {
627
+ if (task.trashedAt !== undefined || task.status !== 'archived') throw new Error('Error: invalid_transition: only archived live tasks can retry session archiving')
628
+ const result = await archiveTaskSessions(task, workspaces.archiveSession)
629
+ json(res, { ok: true, value: result })
630
+ return
631
+ }
618
632
  if (action === 'update') {
619
633
  const ifVersion = num(body, 'ifVersion')
620
634
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
@@ -678,13 +692,16 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
678
692
  if (action === 'move') {
679
693
  const ifVersion = num(body, 'ifVersion')
680
694
  const status = str(body, 'status') ?? ''
695
+ const archiveSessions = body.archiveSessions === true
681
696
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
682
697
  const to = asStatus(status)
683
698
  let next: TaskRecord | undefined
699
+ let beforeTask: TaskRecord | undefined
684
700
  await store.mutate('task-moved', ledger => {
685
701
  const { index, task } = liveTaskAt(ledger, id)
686
702
  if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
687
703
  if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)
704
+ beforeTask = task
688
705
  next = structuredClone(task)
689
706
  next.status = to
690
707
  next.version = task.version + 1
@@ -696,7 +713,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
696
713
  ledger.tasks[index] = next
697
714
  return [next]
698
715
  })
699
- json(res, { ok: true, value: summarize(next!) })
716
+ const sessionArchive = to === 'archived' && archiveSessions
717
+ ? await archiveTaskSessions(beforeTask ?? next!, workspaces.archiveSession)
718
+ : undefined
719
+ json(res, { ok: true, value: { ...summarize(next!), ...(sessionArchive !== undefined ? { sessionArchive } : {}) } })
700
720
  return
701
721
  }
702
722
  if (action === 'reject') {
@@ -909,11 +929,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
909
929
  }
910
930
  // R1: the git merges above are slow — re-find the FRESH task inside
911
931
  // the mutation so a concurrent comment is never overwritten.
912
- const pushComment = (body: string): Promise<void> =>
932
+ const pushComment = (body: string, system?: { key: string; params?: Record<string, string>; rows?: SystemCommentRow[] }): Promise<void> =>
913
933
  store.mutate('comment-added', ledger => {
914
934
  const { index, task: fresh } = liveTaskAt(ledger, id)
915
935
  const next = structuredClone(fresh)
916
- next.comments.push({ id: newCommentId(), body: normalizeBody(body), version: 1, createdAt: options.now() })
936
+ next.comments.push({
937
+ id: newCommentId(),
938
+ body: normalizeBody(body),
939
+ ...(system !== undefined ? {
940
+ systemKey: system.key,
941
+ ...(system.params !== undefined ? { systemParams: system.params } : {}),
942
+ ...(system.rows !== undefined ? { systemRows: system.rows } : {}),
943
+ } : {}),
944
+ version: 1,
945
+ createdAt: options.now(),
946
+ })
917
947
  next.version = fresh.version + 1
918
948
  next.updatedAt = options.now()
919
949
  ledger.tasks[index] = next
@@ -930,7 +960,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
930
960
  if (root.outcome === 'failed') {
931
961
  throw new Error(`Error: invalid_input: ${root.error ?? '合并失败'}`)
932
962
  }
933
- await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`)
963
+ await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`, { key: 'sys.mergeSingle', params: { branch: root.branch } })
934
964
  json(res, { ok: true, value: { merged: true, branch: root.branch } })
935
965
  return
936
966
  }
@@ -942,7 +972,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
942
972
  ? `${labelOf(r.repo)} ✓ 已合并`
943
973
  : r.outcome === 'noop' ? `${labelOf(r.repo)} ⟲ 无新提交` : `${labelOf(r.repo)} ✗ ${(r.error ?? '合并失败').slice(0, 150)}`)
944
974
  .join(';')
945
- await pushComment(`[系统] 分支已按仓库合并(--no-ff):${summary}`)
975
+ await pushComment(`[系统] 分支已按仓库合并(--no-ff):${summary}`, {
976
+ key: 'sys.mergeMulti',
977
+ rows: results.map(r => ({ repo: r.repo, outcome: r.outcome, ...(r.error !== undefined ? { error: r.error.slice(0, 150) } : {}) })),
978
+ })
946
979
  json(res, {
947
980
  ok: true,
948
981
  value: {
@@ -136,6 +136,8 @@ export class SchedulerService {
136
136
  task.comments.push({
137
137
  id: newCommentId(),
138
138
  body: normalizeBody(`[系统] 定时表达式 ${deadCron} 在 4 年内没有可触发时间,已停用定时;请修正 cron 后重新开启。`),
139
+ systemKey: 'sys.cronDead',
140
+ systemParams: { cron: deadCron },
139
141
  version: 1,
140
142
  createdAt: now,
141
143
  })
@@ -213,7 +213,7 @@ export class ExternalSessionSyncService {
213
213
 
214
214
  constructor(private readonly deps: SessionSyncDeps) {
215
215
  this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {
216
- void this.handleSessionEvent(sessionId, event, sessionMeta)
216
+ return this.handleSessionEvent(sessionId, event, sessionMeta)
217
217
  })
218
218
 
219
219
  const interval = deps.scanIntervalMs ?? DEFAULT_SCAN_INTERVAL_MS
@@ -628,6 +628,8 @@ export class ExternalSessionSyncService {
628
628
  task.comments.push({
629
629
  id: newCommentId(),
630
630
  body: normalizeBody(`[系统] 会话执行异常:${errorMessage.slice(0, 300)};任务已退回待办。`),
631
+ systemKey: 'sys.sessionError',
632
+ systemParams: { error: errorMessage.slice(0, 300) },
631
633
  version: 1,
632
634
  createdAt: now,
633
635
  })
@@ -639,6 +641,7 @@ export class ExternalSessionSyncService {
639
641
  task.comments.push({
640
642
  id: newCommentId(),
641
643
  body: normalizeBody('[系统] 会话执行完毕,已自动进入待验收。'),
644
+ systemKey: 'sys.sessionDone',
642
645
  version: 1,
643
646
  createdAt: now,
644
647
  })
@@ -10,66 +10,15 @@
10
10
  */
11
11
  import { readFile } from 'node:fs/promises'
12
12
  import type { TaskTemplate } from '../shared/api.ts'
13
+ import { BUILTIN_TEMPLATE_CONTENT, BUILTIN_TEMPLATE_IDS, type BuiltinTemplateId } from '../shared/builtin-templates.ts'
13
14
 
14
- /** The built-in templates seeded when the side file does not exist yet. */
15
- export const BUILTIN_TEMPLATES: ReadonlyArray<{ id: string; name: string; task: TaskTemplate['task'] }> = [
16
- {
17
- id: 'tpl-feature',
18
- name: '新增功能',
19
- task: {
20
- title: '新增:',
21
- prompt: [
22
- '实现以上新功能并按序交接:',
23
- '1. 明确需求边界与验收标准,列出实现要点',
24
- '2. 实现功能(含类型定义与错误处理)',
25
- '3. 补充测试(单测/回归)',
26
- '4. 运行相关测试套件确认通过',
27
- ].join('\n'),
28
- urgency: 'normal',
29
- checklist: ['实现要点已明确(需求边界与验收标准)', '功能已实现并补充测试', '相关测试套件通过'],
30
- },
31
- },
32
- {
33
- id: 'tpl-bugfix',
34
- name: 'Bug 修复',
35
- task: {
36
- title: '修复:',
37
- prompt: [
38
- '修复以上问题并按序交接:',
39
- '1. 复现问题(写最小复现步骤或测试)',
40
- '2. 定位根因,说明为什么会发生',
41
- '3. 修复并补回归测试',
42
- '4. 运行相关测试套件确认无回归',
43
- ].join('\n'),
44
- urgency: 'urgent',
45
- checklist: ['已复现并定位根因', '修复已提交到任务分支', '回归测试通过'],
46
- },
47
- },
48
- {
49
- id: 'tpl-release',
50
- name: '发布检查',
51
- task: {
52
- title: '发布:',
53
- prompt: '执行发布流程:版本号更新、构建、测试、变更记录,完成后按序交接(不要实际推送/发布,等用户确认)。',
54
- urgency: 'normal',
55
- checklist: ['版本号已更新(package.json 与版本常量同步)', '构建通过', '全部测试通过', '变更记录已写'],
56
- },
57
- },
58
- {
59
- id: 'tpl-patrol',
60
- name: '例行巡检',
61
- task: {
62
- title: '巡检:',
63
- prompt: [
64
- '例行巡检:检查依赖更新、失败测试、明显代码问题与未处理的告警。',
65
- '发现的问题逐条列出(严重度/位置/建议),小问题直接修复,大问题只报告不动手。',
66
- '输出巡检摘要(用 {{lastComments}} 可回看上次巡检结论)。',
67
- ].join('\n'),
68
- urgency: 'relaxed',
69
- execution: { mode: 'scheduled', cron: '0 9 * * 1' },
70
- },
71
- },
72
- ]
15
+ /**
16
+ * The built-in templates seeded when the side file does not exist yet.
17
+ * Seeded from the shared zh content (the side file is plain data; the client
18
+ * resolves the active locale at render time — see shared/builtin-templates.ts).
19
+ */
20
+ export const BUILTIN_TEMPLATES: ReadonlyArray<{ id: BuiltinTemplateId; name: string; task: TaskTemplate['task'] }> =
21
+ BUILTIN_TEMPLATE_IDS.map(id => ({ id, name: BUILTIN_TEMPLATE_CONTENT.zh[id].name, task: BUILTIN_TEMPLATE_CONTENT.zh[id].task }))
73
22
 
74
23
  /** Mint a template id. */
75
24
  function newTemplateId(): string {
package/src/host/tools.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { SessionArchiveResult } from '../shared/api.ts'
2
+ import { archiveTaskSessions } from './archive-sessions.ts'
1
3
  /**
2
4
  * The ten `taskboard_*` agent tools. All writes require a calling agent
3
5
  * session (ownership audit), carry optimistic-version checks, and enforce
@@ -98,11 +100,15 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
98
100
  if (t.checklist !== undefined && t.checklist.length > 0) {
99
101
  const done = t.checklist.filter(i => i.checked).length
100
102
  lines.push(`验收清单 (${done}/${t.checklist.length}):`)
101
- for (const item of t.checklist) {
103
+ for (const [index, item] of t.checklist.entries()) {
102
104
  const mark = item.checked ? '☑' : '☐'
103
105
  const who = item.checkedBy === undefined ? '' : item.checkedBy === 'user' ? ' ·用户勾选' : ` ·agent ${String(item.checkedBy).slice(0, 24)}勾选`
104
106
  const note = item.note !== undefined ? ` ·证据: ${item.note}` : ''
105
- lines.push(` ${mark} ${item.text}${who}${note}`)
107
+ // Carry the checklist item id so `taskboard_checklist check/uncheck` can
108
+ // address it without guessing — a terse render starves the agent (render
109
+ // is fed to the model as result.content). Mirrors the index+id carried
110
+ // by the taskboard_checklist tool output.
111
+ lines.push(` ${mark} [${index + 1}] ${item.text}${who}${note} id=${item.id}`)
106
112
  }
107
113
  }
108
114
  if (t.comments.length > 0) {
@@ -158,6 +164,8 @@ export interface WorkspaceFace {
158
164
  get(id: string): { id: string; path: string; title: string } | undefined
159
165
  /** List all workspaces. */
160
166
  list(): Array<{ id: string; path: string; title: string }>
167
+ /** Archive one session durably (when supported by runtime workspaceRegistry). */
168
+ archiveSession?(sessionId: string): Promise<void>
161
169
  }
162
170
 
163
171
  /** Adapt the real registry to the narrow face. */
@@ -174,6 +182,9 @@ export function workspaceFace(registry: WorkspaceRegistry): WorkspaceFace {
174
182
  return ws === undefined ? undefined : { id: ws.id, path: ws.path, title: ws.title }
175
183
  },
176
184
  list: () => registry.list().map(ws => ({ id: ws.id, path: ws.path, title: ws.title })),
185
+ ...(typeof registry.archiveSession === 'function'
186
+ ? { archiveSession: (sessionId: string) => registry.archiveSession(sessionId as Parameters<WorkspaceRegistry['archiveSession']>[0]) }
187
+ : {}),
177
188
  }
178
189
  }
179
190
 
@@ -407,7 +418,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
407
418
  output: {
408
419
  schema: JSON_OUT,
409
420
  render: (_args, value) => {
410
- const v = value as { task?: { id?: string; status?: string; version?: number } }
421
+ const v = value as { task?: { id?: string; status?: string; version?: number }; sessionArchive?: SessionArchiveResult }
411
422
  const t = v.task
412
423
  return [{ type: 'text', text: t === undefined ? '创建失败。' : `已创建任务 ${t.id} [${t.status}] v${t.version}。写入前先 taskboard_get 读取。` }]
413
424
  },
@@ -497,7 +508,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
497
508
  output: {
498
509
  schema: JSON_OUT,
499
510
  render: (_args, value) => {
500
- const v = value as { task?: { id?: string; status?: string; version?: number } }
511
+ const v = value as { task?: { id?: string; status?: string; version?: number }; sessionArchive?: SessionArchiveResult }
501
512
  const t = v.task
502
513
  return [{ type: 'text', text: t === undefined ? '更新失败。' : `已更新任务 ${t.id},当前 v${t.version} [${t.status}]。` }]
503
514
  },
@@ -550,16 +561,17 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
550
561
  id: { type: 'string', required: true, description: 'Task id.' },
551
562
  status: { type: 'string', required: true, description: 'Target status.' },
552
563
  ifVersion: { type: 'number', required: true, description: 'Task version you read; fails on mismatch.' },
564
+ archiveSessions: { type: 'boolean', description: 'When moving to archived: whether to archive associated execution sessions as well. Defaults to false.' },
553
565
  },
554
566
  output: {
555
567
  schema: JSON_OUT,
556
568
  render: (_args, value) => {
557
- const v = value as { task?: { id?: string; status?: string; version?: number } }
569
+ const v = value as { task?: { id?: string; status?: string; version?: number }; sessionArchive?: SessionArchiveResult }
558
570
  const t = v.task
559
- return [{ type: 'text', text: t === undefined ? '移动失败。' : `任务 ${t.id} 已移到 ${t.status},当前 v${t.version}。` }]
571
+ return [{ type: 'text', text: t === undefined ? '移动失败。' : `任务 ${t.id} 已移到 ${t.status},当前 v${t.version}。${v.sessionArchive === undefined ? '' : ` 会话归档结果:${JSON.stringify(v.sessionArchive)}`}` }]
560
572
  },
561
573
  },
562
- async execute(args: { id: string; status: string; ifVersion: number }, exec: unknown) {
574
+ async execute(args: { id: string; status: string; ifVersion: number; archiveSessions?: boolean }, exec: unknown) {
563
575
  try {
564
576
  const { actor } = caller(exec as ToolRunContext)
565
577
  const to = asStatus(args.status)
@@ -571,9 +583,11 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
571
583
  : undefined
572
584
  // R1: every state guard + the write itself run inside the mutation.
573
585
  let next: TaskRecord | undefined
586
+ let beforeTask: TaskRecord | undefined
574
587
  await store.mutate('task-moved', ledger => {
575
588
  const { index, task } = liveTaskAt(ledger, args.id)
576
589
  versionGuard(task, args.ifVersion)
590
+ beforeTask = task
577
591
 
578
592
  // Code-level gate: agents never complete a task.
579
593
  if (to === 'done') {
@@ -603,7 +617,10 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
603
617
  ledger.tasks[index] = next
604
618
  return [next]
605
619
  })
606
- return json({ task: summarize(next!) })
620
+ const sessionArchive = to === 'archived' && args.archiveSessions === true
621
+ ? await archiveTaskSessions(beforeTask ?? next!, deps.workspaces.archiveSession)
622
+ : undefined
623
+ return json({ task: summarize(next!), ...(sessionArchive !== undefined ? { sessionArchive } : {}) })
607
624
  } catch (error) { fail(error) }
608
625
  },
609
626
  })) as () => void)
@@ -925,4 +942,4 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
925
942
  })) as () => void)
926
943
 
927
944
  return disposers
928
- }
945
+ }
package/src/shared/api.ts CHANGED
@@ -38,7 +38,7 @@ export type ApiResult<T> = ApiOk<T> | ApiFail
38
38
  // ---------------------------------------------------------------------------
39
39
 
40
40
  /** Full-state response (the reconnect baseline after an SSE gap). */
41
- export type StateResponse = TaskLedger
41
+ export type StateResponse = TaskLedger & { capabilities?: { archiveSessions: boolean } }
42
42
 
43
43
  /**
44
44
  * Workspace listing for the UI pickers. `repoCount` (0.6.3): how many repos a
@@ -89,7 +89,10 @@ export type UpdateTaskBody = {
89
89
  }
90
90
 
91
91
  /** Move-task request body (ifVersion mandatory; the user MAY move to done). */
92
- export type MoveTaskBody = { ifVersion: number; status: string }
92
+ export type MoveTaskBody = { ifVersion: number; status: string; archiveSessions?: boolean }
93
+
94
+ export type SessionArchiveResult = { archived: string[]; failed: Array<{ sessionId: string; error: string }>; unsupported: string[] }
95
+ export type MoveTaskResponse = TaskSummary & { sessionArchive?: SessionArchiveResult }
93
96
 
94
97
  /**
95
98
  * Quick-reject request body (card ✗ button): move back to todo plus an
@@ -270,4 +273,4 @@ export type ChangeEvent = {
270
273
  revision: number
271
274
  kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'
272
275
  tasks: TaskSummary[]
273
- }
276
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Built-in task-template content (0.6.4): the four factory templates seeded
3
+ * into the template side file, in both shipped locales. The host seeds the zh
4
+ * copy as the persisted fallback (the side file is plain data); the client
5
+ * resolves the ACTIVE locale's copy at render / prefill time via
6
+ * {@link builtinTemplateContent}, so an English GUI shows English template
7
+ * text without ever re-seeding the side file.
8
+ *
9
+ * Kept in shared/ (not the client i18n dictionaries) because the content is
10
+ * structured task DATA — a checklist is an array and the prompt is multi-line
11
+ * — rather than UI-chrome strings, and because the host needs the same seed
12
+ * content to write the side file.
13
+ *
14
+ * @module dsh-taskboard/shared/builtin-templates
15
+ */
16
+ import type { TaskTemplateSpec } from './api.ts'
17
+
18
+ /** Shipped built-in template locales (mirrors the client i18n LocaleId). */
19
+ export type BuiltinTemplateLocale = 'zh' | 'en'
20
+
21
+ /** One built-in template's localized name + task spec. */
22
+ export interface BuiltinTemplateContent {
23
+ name: string
24
+ task: TaskTemplateSpec
25
+ }
26
+
27
+ /** Stable ids of the factory templates (persisted in the side file). */
28
+ export const BUILTIN_TEMPLATE_IDS = ['tpl-feature', 'tpl-bugfix', 'tpl-release', 'tpl-patrol'] as const
29
+
30
+ export type BuiltinTemplateId = (typeof BUILTIN_TEMPLATE_IDS)[number]
31
+
32
+ /** Built-in template content per locale (name + prefilled task spec). */
33
+ export const BUILTIN_TEMPLATE_CONTENT: Readonly<Record<BuiltinTemplateLocale, Readonly<Record<BuiltinTemplateId, BuiltinTemplateContent>>>> = {
34
+ zh: {
35
+ 'tpl-feature': {
36
+ name: '新增功能',
37
+ task: {
38
+ title: '新增:',
39
+ prompt: [
40
+ '实现以上新功能并按序交接:',
41
+ '1. 明确需求边界与验收标准,列出实现要点',
42
+ '2. 实现功能(含类型定义与错误处理)',
43
+ '3. 补充测试(单测/回归)',
44
+ '4. 运行相关测试套件确认通过',
45
+ ].join('\n'),
46
+ urgency: 'normal',
47
+ checklist: ['实现要点已明确(需求边界与验收标准)', '功能已实现并补充测试', '相关测试套件通过'],
48
+ },
49
+ },
50
+ 'tpl-bugfix': {
51
+ name: 'Bug 修复',
52
+ task: {
53
+ title: '修复:',
54
+ prompt: [
55
+ '修复以上问题并按序交接:',
56
+ '1. 复现问题(写最小复现步骤或测试)',
57
+ '2. 定位根因,说明为什么会发生',
58
+ '3. 修复并补回归测试',
59
+ '4. 运行相关测试套件确认无回归',
60
+ ].join('\n'),
61
+ urgency: 'urgent',
62
+ checklist: ['已复现并定位根因', '修复已提交到任务分支', '回归测试通过'],
63
+ },
64
+ },
65
+ 'tpl-release': {
66
+ name: '发布检查',
67
+ task: {
68
+ title: '发布:',
69
+ prompt: '执行发布流程:版本号更新、构建、测试、变更记录,完成后按序交接(不要实际推送/发布,等用户确认)。',
70
+ urgency: 'normal',
71
+ checklist: ['版本号已更新(package.json 与版本常量同步)', '构建通过', '全部测试通过', '变更记录已写'],
72
+ },
73
+ },
74
+ 'tpl-patrol': {
75
+ name: '例行巡检',
76
+ task: {
77
+ title: '巡检:',
78
+ prompt: [
79
+ '例行巡检:检查依赖更新、失败测试、明显代码问题与未处理的告警。',
80
+ '发现的问题逐条列出(严重度/位置/建议),小问题直接修复,大问题只报告不动手。',
81
+ '输出巡检摘要(用 {{lastComments}} 可回看上次巡检结论)。',
82
+ ].join('\n'),
83
+ urgency: 'relaxed',
84
+ execution: { mode: 'scheduled', cron: '0 9 * * 1' },
85
+ },
86
+ },
87
+ },
88
+ en: {
89
+ 'tpl-feature': {
90
+ name: 'New feature',
91
+ task: {
92
+ title: 'New feature:',
93
+ prompt: [
94
+ 'Implement the new feature above and hand off in order:',
95
+ '1. Clarify the requirement boundaries and acceptance criteria; list the implementation points',
96
+ '2. Implement the feature (including type definitions and error handling)',
97
+ '3. Add tests (unit / regression)',
98
+ '4. Run the relevant test suites and confirm they pass',
99
+ ].join('\n'),
100
+ urgency: 'normal',
101
+ checklist: ['Implementation points clarified (requirement boundaries and acceptance criteria)', 'Feature implemented with tests added', 'Relevant test suites pass'],
102
+ },
103
+ },
104
+ 'tpl-bugfix': {
105
+ name: 'Bug fix',
106
+ task: {
107
+ title: 'Fix:',
108
+ prompt: [
109
+ 'Fix the issue above and hand off in order:',
110
+ '1. Reproduce the issue (write minimal reproduction steps or a test)',
111
+ '2. Locate the root cause and explain why it happens',
112
+ '3. Fix it and add regression tests',
113
+ '4. Run the relevant test suites and confirm there are no regressions',
114
+ ].join('\n'),
115
+ urgency: 'urgent',
116
+ checklist: ['Issue reproduced and root cause located', 'Fix committed to the task branch', 'Regression tests pass'],
117
+ },
118
+ },
119
+ 'tpl-release': {
120
+ name: 'Release check',
121
+ task: {
122
+ title: 'Release:',
123
+ prompt: 'Run the release process: bump the version, build, test, and update the changelog, then hand off in order (do not actually push / publish — wait for user confirmation).',
124
+ urgency: 'normal',
125
+ checklist: ['Version bumped (package.json and version constants in sync)', 'Build passes', 'All tests pass', 'Changelog written'],
126
+ },
127
+ },
128
+ 'tpl-patrol': {
129
+ name: 'Routine patrol',
130
+ task: {
131
+ title: 'Patrol:',
132
+ prompt: [
133
+ 'Routine patrol: check dependency updates, failing tests, obvious code issues, and unhandled alerts.',
134
+ 'List each finding (severity / location / suggestion); fix small issues directly, and only report (do not touch) large ones.',
135
+ 'Output a patrol summary (use {{lastComments}} to review the last patrol\u2019s conclusions).',
136
+ ].join('\n'),
137
+ urgency: 'relaxed',
138
+ execution: { mode: 'scheduled', cron: '0 9 * * 1' },
139
+ },
140
+ },
141
+ },
142
+ }
143
+
144
+ /**
145
+ * Resolve one built-in template's localized content.
146
+ * @param id - the template id.
147
+ * @param locale - the requested locale.
148
+ * @returns the content, or undefined when `id` is not a built-in template id.
149
+ */
150
+ export function builtinTemplateContent(id: string, locale: BuiltinTemplateLocale): BuiltinTemplateContent | undefined {
151
+ if (!(BUILTIN_TEMPLATE_IDS as readonly string[]).includes(id)) return undefined
152
+ return BUILTIN_TEMPLATE_CONTENT[locale][id as BuiltinTemplateId]
153
+ }
@@ -335,6 +335,15 @@ export type Actor =
335
335
  | { kind: 'agent'; sessionId: string }
336
336
  | { kind: 'system' }
337
337
 
338
+ /** Structured row of a multi-repo merge system comment (0.6.4). */
339
+ export type SystemCommentRow = {
340
+ /** Repo path relative to the workspace ('' = the workspace root repo). */
341
+ repo: string
342
+ outcome: 'merged' | 'noop' | 'failed'
343
+ /** Failure reason (verbatim) when outcome = 'failed'. */
344
+ error?: string
345
+ }
346
+
338
347
  /** A progress/report comment on a task. */
339
348
  export type CommentRecord = {
340
349
  id: string
@@ -345,6 +354,16 @@ export type CommentRecord = {
345
354
  createdAt: number
346
355
  /** The session that wrote this comment; absent for user-written ones. */
347
356
  threadId?: string
357
+ /**
358
+ * i18n key of a host-generated system message (0.6.4). The GUI localizes it
359
+ * at render time; `body` stays a zh fallback for agent tools / CSV / raw
360
+ * JSON views.
361
+ */
362
+ systemKey?: string
363
+ /** Flat {name} interpolation params for the system message. */
364
+ systemParams?: Record<string, string>
365
+ /** Structured per-repo rows for the multi-repo merge summary (0.6.4). */
366
+ systemRows?: SystemCommentRow[]
348
367
  }
349
368
 
350
369
  /** One commit produced by an isolated execution (hash + subject). */
@@ -756,6 +775,36 @@ export function syncClaim(task: TaskRecord, to: TaskStatus, now: number, holder?
756
775
  }
757
776
  }
758
777
 
778
+ /**
779
+ * Collect unique execution session IDs associated with a task:
780
+ * - executions with a non-empty `sessionId`
781
+ * Creator and claim sessions may serve other tasks and are never included.
782
+ * @param task - the task record to inspect.
783
+ * @returns an array of distinct session IDs in stable discovery order.
784
+ */
785
+ export function taskAssociatedSessionIds(task: TaskRecord): string[] {
786
+ const seen = new Set<string>()
787
+ const result: string[] = []
788
+ const push = (raw: unknown) => {
789
+ if (typeof raw === 'string') {
790
+ const trimmed = raw.trim()
791
+ if (trimmed.length > 0 && !seen.has(trimmed)) {
792
+ seen.add(trimmed)
793
+ result.push(trimmed)
794
+ }
795
+ }
796
+ }
797
+
798
+ if (Array.isArray(task.executions)) {
799
+ for (const ex of task.executions) {
800
+ if (ex !== null && typeof ex === 'object') {
801
+ push((ex as { sessionId?: unknown }).sessionId)
802
+ }
803
+ }
804
+ }
805
+ return result
806
+ }
807
+
759
808
  /**
760
809
  * Validate and normalize a pinned model: `{ provider, model, reasoningEffort? }`,
761
810
  * provider and model must be non-empty trimmed strings.
@@ -1020,6 +1069,21 @@ export function validateImportedTask(raw: unknown, now: number): { ok: true; tas
1020
1069
  version: numOr(ce, 'version', 1),
1021
1070
  createdAt: numOr(ce, 'createdAt', now),
1022
1071
  ...(typeof ce.threadId === 'string' ? { threadId: ce.threadId } : {}),
1072
+ ...(typeof ce.systemKey === 'string' && /^sys\.[A-Za-z0-9]+$/.test(ce.systemKey) && ce.systemKey.length <= 100
1073
+ ? {
1074
+ systemKey: ce.systemKey,
1075
+ ...(typeof ce.systemParams === 'object' && ce.systemParams !== null && !Array.isArray(ce.systemParams)
1076
+ ? { systemParams: Object.fromEntries(Object.entries(ce.systemParams).filter(([key, value]) => key.length <= 100 && typeof value === 'string' && value.length <= 4000).slice(0, 20)) as Record<string, string> }
1077
+ : {}),
1078
+ ...(Array.isArray(ce.systemRows)
1079
+ ? { systemRows: ce.systemRows.filter((row): row is SystemCommentRow => typeof row === 'object' && row !== null
1080
+ && typeof row.repo === 'string' && (row.repo === '' || isValidRelRepoPath(row.repo))
1081
+ && ['merged', 'noop', 'failed'].includes(row.outcome)
1082
+ && (row.error === undefined || typeof row.error === 'string'))
1083
+ .slice(0, MAX_MIRROR_REPOS).map(row => ({ repo: row.repo, outcome: row.outcome, ...(row.error !== undefined ? { error: row.error.slice(0, 4000) } : {}) })) }
1084
+ : {}),
1085
+ }
1086
+ : {}),
1023
1087
  })
1024
1088
  }
1025
1089
  } else return fail('comments must be an array')
@@ -1,9 +1,9 @@
1
- /**
2
- * The plugin package version shown in the board UI. Kept in sync with
3
- * package.json by a regression test (tests lock drift).
4
- *
5
- * @module dsh-taskboard/shared/version
6
- */
7
-
8
- /** The package version (must equal package.json "version"). */
9
- export const PLUGIN_VERSION = '0.6.5'
1
+ /**
2
+ * The plugin package version shown in the board UI. Kept in sync with
3
+ * package.json by a regression test (tests lock drift).
4
+ *
5
+ * @module dsh-taskboard/shared/version
6
+ */
7
+
8
+ /** The package version (must equal package.json "version"). */
9
+ export const PLUGIN_VERSION = '0.6.7'