dsh-taskboard 0.2.2 → 0.4.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.
Files changed (43) hide show
  1. package/README.md +62 -6
  2. package/lib/client.js +1839 -74
  3. package/lib/host/execution.js +199 -54
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +327 -0
  6. package/lib/host/git.js.map +1 -0
  7. package/lib/host/protocol-text.js +5 -3
  8. package/lib/host/protocol-text.js.map +1 -1
  9. package/lib/host/routes.js +435 -5
  10. package/lib/host/routes.js.map +1 -1
  11. package/lib/host/store.js +12 -0
  12. package/lib/host/store.js.map +1 -1
  13. package/lib/host/templates.js +166 -0
  14. package/lib/host/templates.js.map +1 -0
  15. package/lib/host/tools.js +217 -3
  16. package/lib/host/tools.js.map +1 -1
  17. package/lib/index.js +23 -3
  18. package/lib/index.js.map +1 -1
  19. package/lib/shared/api.js.map +1 -1
  20. package/lib/shared/protocol.js +286 -1
  21. package/lib/shared/protocol.js.map +1 -1
  22. package/package.json +74 -74
  23. package/src/client/api.ts +47 -3
  24. package/src/client/board/ImportModal.tsx +182 -0
  25. package/src/client/board/TaskBoard.tsx +118 -3
  26. package/src/client/board/TaskCard.tsx +9 -0
  27. package/src/client/board/TaskDetail.tsx +360 -4
  28. package/src/client/board/TaskFormModal.tsx +193 -12
  29. package/src/client/board/TemplateManager.tsx +121 -0
  30. package/src/client/controller.ts +238 -11
  31. package/src/client/index.ts +18 -1
  32. package/src/client/styles.ts +198 -0
  33. package/src/host/execution.ts +301 -67
  34. package/src/host/git.ts +370 -0
  35. package/src/host/protocol-text.ts +5 -3
  36. package/src/host/routes.ts +483 -5
  37. package/src/host/store.ts +13 -0
  38. package/src/host/templates.ts +143 -0
  39. package/src/host/tools.ts +215 -2
  40. package/src/index.ts +30 -1
  41. package/src/shared/api.ts +89 -3
  42. package/src/shared/protocol.ts +408 -0
  43. package/src/shared/version.ts +1 -1
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Host-side task-template store (0.4.0): one JSON side file next to the
3
+ * ledger, seeded with the built-in templates on first load, mutated through
4
+ * the same atomic persist discipline as the ledger.
5
+ *
6
+ * Pure data, no Cordis deps — the routes layer owns it and tests drive it
7
+ * directly against a temp dir.
8
+ *
9
+ * @module dsh-taskboard/host/templates
10
+ */
11
+ import { readFile } from 'node:fs/promises'
12
+ import type { TaskTemplate } from '../shared/api.ts'
13
+
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-bugfix',
18
+ name: 'Bug 修复',
19
+ task: {
20
+ title: '修复:',
21
+ prompt: [
22
+ '修复以下问题并按序交接:',
23
+ '1. 复现问题(写最小复现步骤或测试)',
24
+ '2. 定位根因,说明为什么会发生',
25
+ '3. 修复并补回归测试',
26
+ '4. 运行相关测试套件确认无回归',
27
+ ].join('\n'),
28
+ urgency: 'urgent',
29
+ checklist: ['已复现并定位根因', '修复已提交到任务分支', '回归测试通过'],
30
+ },
31
+ },
32
+ {
33
+ id: 'tpl-release',
34
+ name: '发布检查',
35
+ task: {
36
+ title: '发布:',
37
+ prompt: '执行发布流程:版本号更新、构建、测试、变更记录,完成后按序交接(不要实际推送/发布,等用户确认)。',
38
+ urgency: 'normal',
39
+ checklist: ['版本号已更新(package.json 与版本常量同步)', '构建通过', '全部测试通过', '变更记录已写'],
40
+ },
41
+ },
42
+ {
43
+ id: 'tpl-patrol',
44
+ name: '例行巡检',
45
+ task: {
46
+ title: '巡检:',
47
+ prompt: [
48
+ '例行巡检:检查依赖更新、失败测试、明显代码问题与未处理的告警。',
49
+ '发现的问题逐条列出(严重度/位置/建议),小问题直接修复,大问题只报告不动手。',
50
+ '输出巡检摘要(用 {{lastComments}} 可回看上次巡检结论)。',
51
+ ].join('\n'),
52
+ urgency: 'relaxed',
53
+ execution: { mode: 'scheduled', cron: '0 9 * * 1' },
54
+ },
55
+ },
56
+ ]
57
+
58
+ /** Mint a template id. */
59
+ function newTemplateId(): string {
60
+ return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
61
+ }
62
+
63
+ /**
64
+ * The template store. NOT thread-synchronized like the ledger (template
65
+ * writes are rare, human-paced GUI operations; last-write-wins is fine).
66
+ */
67
+ export class TemplateStore {
68
+ private templates: TaskTemplate[] | undefined
69
+ private loaded = false
70
+
71
+ /** @param file - absolute side-file path (next to the ledger). */
72
+ constructor(private readonly file: string) {}
73
+
74
+ /** Load once; a missing file seeds the built-ins; a corrupt file resets. */
75
+ private async ensure(): Promise<void> {
76
+ if (this.loaded) return
77
+ let parsed: TaskTemplate[] | undefined
78
+ try {
79
+ const raw = await readFile(this.file, 'utf8')
80
+ const value = JSON.parse(raw) as { templates?: unknown }
81
+ if (Array.isArray(value.templates)) {
82
+ parsed = value.templates.filter((t): t is TaskTemplate =>
83
+ typeof t === 'object' && t !== null && typeof (t as TaskTemplate).id === 'string'
84
+ && typeof (t as TaskTemplate).name === 'string' && typeof (t as TaskTemplate).task === 'object')
85
+ }
86
+ } catch { /* missing or corrupt → seed */ }
87
+ if (parsed === undefined) {
88
+ const now = Date.now()
89
+ parsed = BUILTIN_TEMPLATES.map((t, i) => ({ ...t, task: { ...t.task }, builtin: true, createdAt: now, updatedAt: now + i }))
90
+ try { await this.persist(parsed) } catch { /* best effort — the seed returns in-memory */ }
91
+ }
92
+ this.templates = parsed
93
+ this.loaded = true
94
+ }
95
+
96
+ /** Atomic persist (temp + rename), same discipline as the ledger. */
97
+ private async persist(templates: TaskTemplate[]): Promise<void> {
98
+ const { mkdir, writeFile, rename } = await import('node:fs/promises')
99
+ const { dirname, join } = await import('node:path')
100
+ await mkdir(dirname(this.file), { recursive: true })
101
+ const temp = join(dirname(this.file), `.${Math.random().toString(36).slice(2)}.tmp`)
102
+ await writeFile(temp, JSON.stringify({ templates }, null, 2), 'utf8')
103
+ await rename(temp, this.file)
104
+ }
105
+
106
+ /** All templates (oldest first). */
107
+ async list(): Promise<TaskTemplate[]> {
108
+ await this.ensure()
109
+ return (this.templates ?? []).slice()
110
+ }
111
+
112
+ /**
113
+ * Create or replace a template by id (a body without id creates).
114
+ * @returns the stored template.
115
+ */
116
+ async upsert(input: { id?: string; name: string; task: TaskTemplate['task'] }): Promise<TaskTemplate> {
117
+ await this.ensure()
118
+ const templates = this.templates ?? []
119
+ const name = input.name.trim()
120
+ if (name.length === 0 || name.length > 60) throw new Error('模板名必须 1..60 字符')
121
+ const now = Date.now()
122
+ const existing = input.id !== undefined ? templates.find(t => t.id === input.id) : undefined
123
+ const stored: TaskTemplate = existing !== undefined
124
+ ? { ...existing, name, task: input.task, updatedAt: now }
125
+ : { id: input.id ?? newTemplateId(), name, task: input.task, createdAt: now, updatedAt: now }
126
+ const index = existing !== undefined ? templates.indexOf(existing) : -1
127
+ if (index >= 0) templates[index] = stored
128
+ else templates.push(stored)
129
+ await this.persist(templates)
130
+ return stored
131
+ }
132
+
133
+ /** Delete a template by id; returns whether it existed. */
134
+ async remove(id: string): Promise<boolean> {
135
+ await this.ensure()
136
+ const templates = this.templates ?? []
137
+ const index = templates.findIndex(t => t.id === id)
138
+ if (index < 0) return false
139
+ templates.splice(index, 1)
140
+ await this.persist(templates)
141
+ return true
142
+ }
143
+ }
package/src/host/tools.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The eight `taskboard_*` agent tools. All writes require a calling agent
2
+ * The ten `taskboard_*` agent tools. All writes require a calling agent
3
3
  * session (ownership audit), carry optimistic-version checks, and enforce
4
4
  * the protocol gates in CODE, not in prompt text:
5
5
  *
@@ -8,6 +8,8 @@
8
8
  * match the task's project (claim boundary)
9
9
  * - taking over a task held by another session is rejected
10
10
  * - `delete` for agent callers only sets the soft-delete marker
11
+ * - checklist items may be added/checked by agents, but checking never
12
+ * completes the task (done stays user-only)
11
13
  *
12
14
  * OUTPUT CONTRACT (lesson: registry `createSuccessResult` renders
13
15
  * `output.render(args, value)` into `result.content`, and the loop feeds
@@ -21,22 +23,28 @@
21
23
  import type { WorkspaceRegistry } from '@deepseek-ai/dsh-workspace'
22
24
  import { defineTool } from './sdk.ts'
23
25
  import {
26
+ MAX_CHECKLIST_ITEMS,
27
+ asIsolation,
24
28
  asStatus,
25
29
  asUrgency,
26
30
  canTransition,
31
+ checklistFromTexts,
27
32
  effectivePrompt,
28
33
  isClaim,
29
34
  isClaimedBy,
35
+ newChecklistItemId,
30
36
  newCommentId,
31
37
  newTaskId,
32
38
  normalizeBody,
33
39
  normalizeExecution,
40
+ normalizeExecutionReport,
34
41
  normalizeModel,
35
42
  normalizePrompt,
36
43
  normalizeTitle,
37
44
  summarize,
38
45
  syncClaim,
39
46
  type Actor,
47
+ type ChecklistItem,
40
48
  type TaskModel,
41
49
  type TaskRecord,
42
50
  } from '../shared/protocol.ts'
@@ -54,6 +62,7 @@ function taskLine(t: {
54
62
  executionMode: string
55
63
  commentCount?: number
56
64
  lastExecutionOutcome?: string
65
+ checklist?: { done: number; total: number }
57
66
  trashed?: boolean
58
67
  }): string {
59
68
  const parts = [
@@ -63,6 +72,7 @@ function taskLine(t: {
63
72
  if (t.blocked) parts.push('·受阻')
64
73
  if (t.executionMode === 'scheduled') parts.push('·定时')
65
74
  if (t.commentCount !== undefined && t.commentCount > 0) parts.push(`·评论${t.commentCount}`)
75
+ if (t.checklist !== undefined && t.checklist.total > 0) parts.push(`·清单${t.checklist.done}/${t.checklist.total}`)
66
76
  if (t.lastExecutionOutcome !== undefined) parts.push(`·上次执行${t.lastExecutionOutcome}`)
67
77
  if (t.trashed === true) parts.push('·已删')
68
78
  return parts.join(' ')
@@ -74,13 +84,25 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
74
84
  `任务 ${t.id} 「${t.title}」`,
75
85
  `状态: ${t.status} (v${t.version}) · 紧急度: ${t.urgency} · 项目: ${t.workspaceId}${t.blocked ? ' · 受阻' : ''}`,
76
86
  `执行方式: ${t.execution.mode}${t.execution.cron !== undefined ? ` cron=${t.execution.cron}` : ''}`,
87
+ `隔离: ${t.isolation === 'none' ? '关闭(原目录执行)' : 'Git Worktree'}${t.branch !== undefined ? `(分支 ${t.branch})` : ''}`,
77
88
  ]
78
89
  const holder = isClaimedBy(t)
79
90
  if (holder !== undefined) lines.push(`认领: agent ${String(holder).slice(0, 24)}(持有期间其他会话不可移动)`)
80
91
  if (t.execution.nextRunAt !== undefined) lines.push(`下次触发: ${new Date(t.execution.nextRunAt).toISOString()}`)
81
92
  if (t.model !== undefined) lines.push(`固定模型: ${t.model.provider}/${t.model.model}`)
93
+ if (t.presetId !== undefined) lines.push(`执行模式: ${t.presetId}(未指定时为部署默认 preset)`)
82
94
  lines.push(`描述: ${t.description.length > 0 ? t.description : '(无)'}`)
83
95
  lines.push(`执行 Prompt: ${t.effectivePrompt ?? effectivePrompt(t)}`)
96
+ if (t.checklist !== undefined && t.checklist.length > 0) {
97
+ const done = t.checklist.filter(i => i.checked).length
98
+ lines.push(`验收清单 (${done}/${t.checklist.length}):`)
99
+ for (const item of t.checklist) {
100
+ const mark = item.checked ? '☑' : '☐'
101
+ const who = item.checkedBy === undefined ? '' : item.checkedBy === 'user' ? ' ·用户勾选' : ` ·agent ${String(item.checkedBy).slice(0, 24)}勾选`
102
+ const note = item.note !== undefined ? ` ·证据: ${item.note}` : ''
103
+ lines.push(` ${mark} ${item.text}${who}${note}`)
104
+ }
105
+ }
84
106
  if (t.comments.length > 0) {
85
107
  lines.push(`评论 (${t.comments.length}):`)
86
108
  for (const c of t.comments) {
@@ -95,7 +117,8 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
95
117
  for (const e of t.executions) {
96
118
  const at = e.startedAt !== undefined ? new Date(e.startedAt).toISOString() : '?'
97
119
  const err = e.error !== undefined ? ` 错误: ${e.error}` : ''
98
- lines.push(` - [${e.trigger} ${at}] ${e.outcome}${err}`)
120
+ const report = e.report !== undefined ? ' [已交报告]' : ''
121
+ lines.push(` - [${e.trigger} ${at}] ${e.outcome}${report}${err}`)
99
122
  }
100
123
  } else {
101
124
  lines.push('执行记录: 无')
@@ -348,6 +371,19 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
348
371
  model: { type: 'string', description: 'Provider-owned model id.' },
349
372
  },
350
373
  },
374
+ isolation: {
375
+ type: 'string',
376
+ description: 'Code isolation for executions: "worktree" (default — each run gets a fresh git worktree on branch task/<标题>+<taskId>) or "none" (run in the project directory, zero git interaction).',
377
+ },
378
+ presetId: {
379
+ type: 'string',
380
+ description: 'Agent preset the execution session is composed from (its tool set / persona); default = the deployment default preset. Optional.',
381
+ },
382
+ checklist: {
383
+ type: 'array',
384
+ description: `Acceptance checklist (DoD) item texts (≤${MAX_CHECKLIST_ITEMS} × 200 chars); agents check them off at handoff, the user reviews.`,
385
+ items: { type: 'string' },
386
+ },
351
387
  },
352
388
  output: {
353
389
  schema: JSON_OUT,
@@ -366,6 +402,9 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
366
402
  prompt?: string
367
403
  execution?: { mode?: string; cron?: string }
368
404
  model?: { provider?: string; model?: string }
405
+ isolation?: string
406
+ presetId?: string
407
+ checklist?: string[]
369
408
  }, exec: unknown) {
370
409
  try {
371
410
  const { actor } = caller(exec as ToolRunContext)
@@ -380,6 +419,9 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
380
419
  }
381
420
  const execution = normalizeExecution(args.execution ?? {}, deps.now())
382
421
  const model = args.model !== undefined ? checkModel(deps, args.model) : undefined
422
+ const isolation = args.isolation === undefined ? undefined : asIsolation(args.isolation)
423
+ const presetId = args.presetId?.trim() || undefined
424
+ const checklist = args.checklist !== undefined ? checklistFromTexts(args.checklist) : undefined
383
425
  const now = deps.now()
384
426
  const task: TaskRecord = {
385
427
  id: newTaskId(),
@@ -392,6 +434,9 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
392
434
  blocked: false,
393
435
  execution,
394
436
  model,
437
+ ...(isolation !== undefined ? { isolation } : {}),
438
+ ...(presetId !== undefined ? { presetId } : {}),
439
+ ...(checklist !== undefined ? { checklist } : {}),
395
440
  version: 1,
396
441
  createdAt: now,
397
442
  updatedAt: now,
@@ -649,5 +694,173 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
649
694
  },
650
695
  })) as () => void)
651
696
 
697
+ // -------------------------------------------------------------- checklist
698
+ disposers.push(register(defineTool({
699
+ name: 'taskboard_checklist',
700
+ description:
701
+ `Manage the task's acceptance checklist (DoD). Actions: "add" (append item texts, ≤10 per call), `
702
+ + '"check" (mark an item done, with an optional evidence note), "uncheck" (reopen an item). '
703
+ + 'Checking items NEVER completes the task — done stays a user-only action. Requires ifVersion.',
704
+ parameters: {
705
+ id: { type: 'string', required: true, description: 'Task id.' },
706
+ action: { type: 'string', required: true, description: 'add | check | uncheck.' },
707
+ ifVersion: { type: 'number', required: true, description: 'Task version you read; fails on mismatch.' },
708
+ items: {
709
+ type: 'array',
710
+ description: 'Item texts to append (action=add only; 1..10 per call, 200 chars each).',
711
+ items: { type: 'string' },
712
+ },
713
+ itemId: { type: 'string', description: 'The checklist item id (action=check/uncheck).' },
714
+ note: { type: 'string', description: 'Evidence note recorded with the check (≤400 chars).' },
715
+ },
716
+ output: {
717
+ schema: JSON_OUT,
718
+ render: (_args, value) => {
719
+ const v = value as { task?: { id?: string; version?: number }; checklist?: Array<Record<string, unknown>>; done?: number; total?: number }
720
+ if (v.task === undefined || v.checklist === undefined) return [{ type: 'text', text: '清单操作失败。' }]
721
+ const lines = v.checklist.map((i, index) => `${i.checked === true ? '☑' : '☐'} [${index + 1}] ${String(i.text)}${i.note !== undefined ? `(证据: ${String(i.note)})` : ''} id=${String(i.id)}`)
722
+ return [{
723
+ type: 'text',
724
+ text: `任务 ${v.task.id} 验收清单 ${v.done ?? 0}/${v.total ?? 0} 已完成,当前 v${v.task.version}:\n${lines.join('\n')}`,
725
+ }]
726
+ },
727
+ },
728
+ async execute(args: {
729
+ id: string
730
+ action: string
731
+ ifVersion: number
732
+ items?: string[]
733
+ itemId?: string
734
+ note?: string
735
+ }, exec: unknown) {
736
+ try {
737
+ const { actor } = caller(exec as ToolRunContext)
738
+ const task = store.get(args.id)
739
+ if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
740
+ versionGuard(task, args.ifVersion)
741
+ if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
742
+ const next: TaskRecord = structuredClone(task)
743
+ const checklist: ChecklistItem[] = next.checklist === undefined ? [] : [...next.checklist]
744
+
745
+ if (args.action === 'add') {
746
+ const texts = args.items ?? []
747
+ if (texts.length === 0 || texts.length > 10) {
748
+ throw new ToolError(ERR.invalidInput, 'items must carry 1..10 texts per add call')
749
+ }
750
+ if (checklist.length + texts.length > MAX_CHECKLIST_ITEMS) {
751
+ throw new ToolError(ERR.invalidInput, `checklist may hold at most ${MAX_CHECKLIST_ITEMS} items (currently ${checklist.length})`)
752
+ }
753
+ checklist.push(...checklistFromTexts(texts))
754
+ } else if (args.action === 'check') {
755
+ if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for check')
756
+ const item = checklist.find(i => i.id === args.itemId)
757
+ if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
758
+ const note = args.note !== undefined && args.note.trim().length > 0 ? args.note.trim().slice(0, 400) : undefined
759
+ item.checked = true
760
+ item.checkedBy = actor.sessionId
761
+ item.checkedAt = deps.now()
762
+ if (note !== undefined) item.note = note
763
+ } else if (args.action === 'uncheck') {
764
+ if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for uncheck')
765
+ const item = checklist.find(i => i.id === args.itemId)
766
+ if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
767
+ item.checked = false
768
+ delete item.checkedBy
769
+ delete item.checkedAt
770
+ delete item.note
771
+ } else {
772
+ throw new ToolError(ERR.invalidInput, `action must be add | check | uncheck (got "${args.action}")`)
773
+ }
774
+
775
+ if (checklist.length > 0) next.checklist = checklist
776
+ else delete next.checklist
777
+ next.version = task.version + 1
778
+ next.updatedAt = deps.now()
779
+ next.updatedBy = actor
780
+ await store.mutate('task-updated', ledger => {
781
+ const i = ledger.tasks.findIndex(t => t.id === args.id)
782
+ ledger.tasks[i] = next
783
+ return [next]
784
+ })
785
+ const progress = next.checklist !== undefined
786
+ ? { done: next.checklist.filter(i => i.checked).length, total: next.checklist.length }
787
+ : { done: 0, total: 0 }
788
+ return json({ task: { id: next.id, version: next.version }, checklist: next.checklist ?? [], ...progress })
789
+ } catch (error) { fail(error) }
790
+ },
791
+ })) as () => void)
792
+
793
+ // ------------------------------------------------------- execution report
794
+ disposers.push(register(defineTool({
795
+ name: 'taskboard_execution_report',
796
+ description:
797
+ 'Submit the structured execution report for the task you are currently executing (summary / changed '
798
+ + 'files / how you verified / artifacts / remaining risk). Submit BEFORE moving the task to in_review; '
799
+ + 'a later submission overwrites the previous report. Commits and diffs are host-collected — do not repeat them.',
800
+ parameters: {
801
+ summary: { type: 'string', required: true, description: 'What was done (1..2000 chars).' },
802
+ changedFiles: {
803
+ type: 'array',
804
+ description: 'Files you changed (paths, ≤50 × 300 chars).',
805
+ items: { type: 'string' },
806
+ },
807
+ checks: {
808
+ type: 'array',
809
+ description: 'How the work was verified (e.g. test commands + outcomes, ≤50 entries).',
810
+ items: { type: 'string' },
811
+ },
812
+ artifacts: {
813
+ type: 'array',
814
+ description: 'Artifacts worth reviewing (build outputs, screenshots, docs, ≤30 entries).',
815
+ items: { type: 'string' },
816
+ },
817
+ risk: { type: 'string', description: 'Known remaining risks or follow-ups (≤2000 chars, optional).' },
818
+ },
819
+ output: {
820
+ schema: JSON_OUT,
821
+ render: (_args, value) => {
822
+ const v = value as { taskId?: string; executionId?: string; report?: { summary?: string } }
823
+ if (v.taskId === undefined || v.report === undefined) return [{ type: 'text', text: '报告提交失败。' }]
824
+ return [{
825
+ type: 'text',
826
+ text: `执行报告已记录到任务 ${v.taskId}(执行 ${v.executionId}):${v.report.summary?.slice(0, 120) ?? ''}\n`
827
+ + '接下来:taskboard_comment_add 留交接评论,然后 taskboard_move 移至待验收 in_review。',
828
+ }]
829
+ },
830
+ },
831
+ async execute(args: {
832
+ summary: string
833
+ changedFiles?: string[]
834
+ checks?: string[]
835
+ artifacts?: string[]
836
+ risk?: string
837
+ }, exec: unknown) {
838
+ try {
839
+ const { sessionId } = caller(exec as ToolRunContext)
840
+ const report = normalizeExecutionReport(args)
841
+ // Locate the RUNNING execution this session owns — reports attach to
842
+ // the live run, so the agent never needs to know execution ids.
843
+ let taskId: string | undefined
844
+ let executionId: string | undefined
845
+ await store.mutate('execution-recorded', ledger => {
846
+ for (const task of ledger.tasks) {
847
+ const execution = task.executions.find(e => e.sessionId === sessionId && e.outcome === 'running')
848
+ if (execution !== undefined) {
849
+ execution.report = report
850
+ taskId = task.id
851
+ executionId = execution.id
852
+ return [task]
853
+ }
854
+ }
855
+ return undefined
856
+ })
857
+ if (taskId === undefined || executionId === undefined) {
858
+ throw new ToolError(ERR.forbidden, 'no running execution belongs to this session — the report can only be submitted while the taskboard execution session is still running')
859
+ }
860
+ return json({ taskId, executionId, report })
861
+ } catch (error) { fail(error) }
862
+ },
863
+ })) as () => void)
864
+
652
865
  return disposers
653
866
  }
package/src/index.ts CHANGED
@@ -21,15 +21,20 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
21
21
  import type {} from '@deepseek-ai/dsh-agent'
22
22
  import { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'
23
23
  import { DEFAULT_MAX_CONCURRENT, ExecutionService, type EventsFace } from './host/execution.ts'
24
+ import { createGitFace } from './host/git.ts'
24
25
  import { registerTaskboardRoutes } from './host/routes.ts'
25
26
  import { SchedulerService } from './host/scheduler.ts'
26
27
  import { dshHomePath } from './host/sdk.ts'
27
28
  import { TaskStore } from './host/store.ts'
29
+ import { TemplateStore } from './host/templates.ts'
28
30
  import { registerTaskboardTools, workspaceFace } from './host/tools.ts'
29
31
 
30
32
  /** Ledger file name under the DSH home. */
31
33
  export const LEDGER_FILE = 'dsh-taskboard.json'
32
34
 
35
+ /** Task-template side file name under the DSH home (0.4.0). */
36
+ export const TEMPLATES_FILE = 'dsh-taskboard-templates.json'
37
+
33
38
  /** Cordis plugin name. */
34
39
  export const name = 'dsh-taskboard'
35
40
 
@@ -42,6 +47,7 @@ export const inject = ['tools', 'systemPrompt']
42
47
  */
43
48
  export function apply(ctx: Context): void {
44
49
  const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })
50
+ const templates = new TemplateStore(dshHomePath(TEMPLATES_FILE))
45
51
  const now = () => Date.now()
46
52
  // Global execution concurrency cap (DSH_TASKBOARD_MAX_CONCURRENT overrides).
47
53
  const maxConcurrent = Math.max(1, Number.parseInt(process.env.DSH_TASKBOARD_MAX_CONCURRENT ?? '', 10) || DEFAULT_MAX_CONCURRENT)
@@ -85,6 +91,10 @@ export function apply(ctx: Context): void {
85
91
  }),
86
92
  }
87
93
 
94
+ // The narrow git face shared by execution (worktree isolation) and the
95
+ // routes (merge / remove / workspace detection).
96
+ const git = createGitFace()
97
+
88
98
  wsCtx.inject(['agents'], (agentCtx: Context) => {
89
99
  const execution = new ExecutionService({
90
100
  store,
@@ -100,6 +110,23 @@ export function apply(ctx: Context): void {
100
110
  },
101
111
  events,
102
112
  now,
113
+ git,
114
+ // Preset composition (0.3.3): mirror apiproxy's composeAgent — resolve
115
+ // the id BEFORE creation (the session header snapshots meta), mount
116
+ // inside the factory's setup callback. No roster service → undefined
117
+ // (bare host composition, the pre-preset behavior).
118
+ composeAgent: async (presetId) => {
119
+ const presets = agentCtx.get('agentPresets') as {
120
+ resolve(id?: string): Promise<{ id: string }>
121
+ mount(agentCtx: unknown, id?: string): Promise<unknown>
122
+ } | undefined
123
+ if (presets === undefined) return undefined
124
+ const resolved = await presets.resolve(presetId)
125
+ return {
126
+ agentPreset: resolved.id,
127
+ setup: async (ctx: unknown) => { await presets.mount(ctx, resolved.id) },
128
+ }
129
+ },
103
130
  renameSession: (sessionId, title) => {
104
131
  // Best-effort: pin the execution session's title to the task title
105
132
  // through the log-backed session-title service (user-sourced rename).
@@ -127,9 +154,11 @@ export function apply(ctx: Context): void {
127
154
  store,
128
155
  workspaces: workspaceFace(wsCtx.workspaceRegistry),
129
156
  now,
130
- run: (taskId: string) => execution.run(taskId, 'manual'),
157
+ run: (taskId: string, runOptions?: { reuseWorktree?: boolean }) => execution.run(taskId, 'manual', runOptions),
131
158
  cancel: (taskId: string) => execution.cancel(taskId),
132
159
  modelProviders,
160
+ git,
161
+ templates,
133
162
  })
134
163
  return () => disposeRoutes?.()
135
164
  })
package/src/shared/api.ts CHANGED
@@ -41,7 +41,7 @@ export type ApiResult<T> = ApiOk<T> | ApiFail
41
41
  export type StateResponse = TaskLedger
42
42
 
43
43
  /** Workspace listing for the UI pickers. */
44
- export type WorkspaceView = { id: string; path: string; title: string; sessionCount: number }
44
+ export type WorkspaceView = { id: string; path: string; title: string; sessionCount: number; gitAvailable?: boolean }
45
45
 
46
46
  /** Create-task request body (actor is always the GUI user). */
47
47
  export type CreateTaskBody = {
@@ -52,6 +52,12 @@ export type CreateTaskBody = {
52
52
  prompt?: string
53
53
  execution?: { mode?: string; cron?: string }
54
54
  model?: { provider: string; model: string }
55
+ /** Code isolation for executions ('worktree' | 'none'); omitted = default. */
56
+ isolation?: string
57
+ /** Agent preset for execution sessions; omitted = deployment default. */
58
+ presetId?: string
59
+ /** Acceptance checklist item texts (host mints ids, all unchecked). */
60
+ checklist?: string[]
55
61
  }
56
62
 
57
63
  /** Update-task request body (ifVersion mandatory). */
@@ -66,6 +72,12 @@ export type UpdateTaskBody = {
66
72
  workspaceId?: string
67
73
  execution?: { mode?: string; cron?: string }
68
74
  model?: { provider: string; model: string } | null
75
+ /** Change isolation; locked once the task has execution history. */
76
+ isolation?: string
77
+ /** Change the execution preset (takes effect on the next run). */
78
+ presetId?: string | null
79
+ /** Replace the whole checklist (GUI owner surface); null clears it. */
80
+ checklist?: unknown
69
81
  }
70
82
 
71
83
  /** Move-task request body (ifVersion mandatory; the user MAY move to done). */
@@ -84,8 +96,82 @@ export type CommentBody = { body: string }
84
96
  /** Delete request body (purge=true physically removes a trashed task). */
85
97
  export type DeleteTaskBody = { ifVersion?: number; purge?: boolean }
86
98
 
87
- /** Run request body (P3). */
88
- export type RunTaskBody = Record<string, never>
99
+ /** Run request body; `reuse: true` = 续跑 (keep a live worktree as-is). */
100
+ export type RunTaskBody = { reuse?: boolean }
101
+
102
+ /** Merge outcome: `noop: true` = the branch had no commits over HEAD (nothing merged). */
103
+ export type MergeBranchResponse = { merged: boolean; noop?: boolean; branch: string }
104
+
105
+ /** Remove a task's worktree; optionally delete its branch too. */
106
+ export type WorktreeRemoveBody = { deleteBranch?: boolean }
107
+
108
+ /** One orphan worktree directory (exists on disk, owned by no live task). */
109
+ export type OrphanWorktree = { workspaceId: string; workspacePath: string; taskId: string; path: string }
110
+
111
+ /** A git-enabled workspace whose .gitignore does not cover the worktree dir. */
112
+ export type GitignoreSuggestion = { workspaceId: string; workspacePath: string }
113
+
114
+ /** Health-diagnostics response (⚙ panel). */
115
+ export type DiagnosticsResponse = {
116
+ revision: number
117
+ tasks: number
118
+ /** Executions currently marked `running`. */
119
+ staleRunning: number
120
+ /** Worktree directories whose task no longer exists in the ledger. */
121
+ orphanWorktrees: OrphanWorktree[]
122
+ /** Git workspaces whose .gitignore does not ignore the worktree dir. */
123
+ gitIgnoreSuggestions: GitignoreSuggestion[]
124
+ }
125
+
126
+ /** Fields a task template may prefill (0.4.0). */
127
+ export type TaskTemplateSpec = {
128
+ title?: string
129
+ description?: string
130
+ prompt?: string
131
+ urgency?: string
132
+ execution?: { mode?: string; cron?: string }
133
+ model?: { provider: string; model: string }
134
+ isolation?: string
135
+ presetId?: string
136
+ /** Checklist item texts (host mints ids at create time). */
137
+ checklist?: string[]
138
+ }
139
+
140
+ /** One reusable task template (0.4.0). */
141
+ export type TaskTemplate = {
142
+ id: string
143
+ name: string
144
+ task: TaskTemplateSpec
145
+ /** Seeded built-in templates (kept on load, deletable like any other). */
146
+ builtin?: boolean
147
+ createdAt: number
148
+ updatedAt: number
149
+ }
150
+
151
+ /** Templates listing response. */
152
+ export type TemplatesResponse = { templates: TaskTemplate[] }
153
+
154
+ /** Import dry-run response (0.4.0): every task classified, nothing written. */
155
+ export type ImportPreviewResponse = {
156
+ plan: {
157
+ create: Array<{ id: string; title: string; status: string }>
158
+ overwrite: Array<{ id: string; title: string; status: string }>
159
+ invalid: Array<{ id?: string; reason: string }>
160
+ }
161
+ }
162
+
163
+ /** Import commit response. */
164
+ export type ImportCommitResponse = {
165
+ mode: 'merge' | 'replace'
166
+ created: number
167
+ overwritten: number
168
+ replacedTotal?: number
169
+ /** The backup file written BEFORE a replace wiped the live ledger. */
170
+ backupFile?: string
171
+ }
172
+
173
+ /** Diff-viewer response (0.4.0). */
174
+ export type DiffResponse = { diff: string; truncated: boolean }
89
175
 
90
176
  /** One task (full record) response. */
91
177
  export type TaskResponse = TaskRecord