dsh-taskboard 0.1.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 (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +169 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +2085 -0
  5. package/lib/host/execution.js +189 -0
  6. package/lib/host/execution.js.map +1 -0
  7. package/lib/host/protocol-text.js +37 -0
  8. package/lib/host/protocol-text.js.map +1 -0
  9. package/lib/host/routes.js +369 -0
  10. package/lib/host/routes.js.map +1 -0
  11. package/lib/host/scheduler.js +91 -0
  12. package/lib/host/scheduler.js.map +1 -0
  13. package/lib/host/sdk.js +145 -0
  14. package/lib/host/sdk.js.map +1 -0
  15. package/lib/host/store.js +112 -0
  16. package/lib/host/store.js.map +1 -0
  17. package/lib/host/tools.js +620 -0
  18. package/lib/host/tools.js.map +1 -0
  19. package/lib/index.js +91 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/invariant.js +22 -0
  22. package/lib/invariant.js.map +1 -0
  23. package/lib/shared/api.js +9 -0
  24. package/lib/shared/api.js.map +1 -0
  25. package/lib/shared/protocol.js +279 -0
  26. package/lib/shared/protocol.js.map +1 -0
  27. package/package.json +74 -0
  28. package/src/client/api.ts +90 -0
  29. package/src/client/board/NewTaskModal.tsx +8 -0
  30. package/src/client/board/TaskBoard.tsx +184 -0
  31. package/src/client/board/TaskCard.tsx +61 -0
  32. package/src/client/board/TaskDetail.tsx +210 -0
  33. package/src/client/board/TaskFormModal.tsx +257 -0
  34. package/src/client/board-mount.tsx +92 -0
  35. package/src/client/controller.ts +241 -0
  36. package/src/client/index.ts +87 -0
  37. package/src/client/sidebar-entry.ts +165 -0
  38. package/src/client/styles.ts +391 -0
  39. package/src/host/execution.ts +244 -0
  40. package/src/host/protocol-text.ts +37 -0
  41. package/src/host/routes.ts +387 -0
  42. package/src/host/scheduler.ts +107 -0
  43. package/src/host/sdk.ts +200 -0
  44. package/src/host/store.ts +139 -0
  45. package/src/host/tools.ts +631 -0
  46. package/src/index.ts +124 -0
  47. package/src/invariant.ts +22 -0
  48. package/src/shared/api.ts +98 -0
  49. package/src/shared/protocol.ts +475 -0
@@ -0,0 +1,631 @@
1
+ /**
2
+ * The eight `taskboard_*` agent tools. All writes require a calling agent
3
+ * session (ownership audit), carry optimistic-version checks, and enforce
4
+ * the protocol gates in CODE, not in prompt text:
5
+ *
6
+ * - `move → done` is rejected for agent callers (user confirmation only)
7
+ * - `move todo → in_progress` requires the calling session's workspace to
8
+ * match the task's project (claim boundary)
9
+ * - taking over a task held by another session is rejected
10
+ * - `delete` for agent callers only sets the soft-delete marker
11
+ *
12
+ * OUTPUT CONTRACT (lesson: registry `createSuccessResult` renders
13
+ * `output.render(args, value)` into `result.content`, and the loop feeds
14
+ * exactly that content to the model — the raw JSON `value` never reaches
15
+ * the model): render() IS the model-facing tool result. Every render must
16
+ * carry the complete facts an agent needs to act (ids, versions, statuses);
17
+ * a "terse UI summary" here starves the agent.
18
+ *
19
+ * @module dsh-taskboard/host/tools
20
+ */
21
+ import type { WorkspaceRegistry } from '@deepseek-ai/dsh-workspace'
22
+ import { defineTool } from './sdk.ts'
23
+ import {
24
+ asStatus,
25
+ asUrgency,
26
+ canTransition,
27
+ effectivePrompt,
28
+ isClaim,
29
+ newCommentId,
30
+ newTaskId,
31
+ normalizeBody,
32
+ normalizeExecution,
33
+ normalizePrompt,
34
+ normalizeTitle,
35
+ summarize,
36
+ type Actor,
37
+ type TaskModel,
38
+ type TaskRecord,
39
+ } from '../shared/protocol.ts'
40
+ import type { TaskStore } from './store.ts'
41
+
42
+ /** Render side: one compact task line (id/status/version are load-bearing). */
43
+ function taskLine(t: {
44
+ id: string
45
+ title: string
46
+ status: string
47
+ urgency: string
48
+ version: number
49
+ workspaceId: string
50
+ blocked: boolean
51
+ executionMode: string
52
+ commentCount?: number
53
+ lastExecutionOutcome?: string
54
+ trashed?: boolean
55
+ }): string {
56
+ const parts = [
57
+ `- ${t.id} [${t.status}] v${t.version} · ${t.urgency} · 项目 ${t.workspaceId}`,
58
+ `「${t.title}」`,
59
+ ]
60
+ if (t.blocked) parts.push('·受阻')
61
+ if (t.executionMode === 'scheduled') parts.push('·定时')
62
+ if (t.commentCount !== undefined && t.commentCount > 0) parts.push(`·评论${t.commentCount}`)
63
+ if (t.lastExecutionOutcome !== undefined) parts.push(`·上次执行${t.lastExecutionOutcome}`)
64
+ if (t.trashed === true) parts.push('·已删')
65
+ return parts.join(' ')
66
+ }
67
+
68
+ /** Render side: the full task detail block (everything an executor needs). */
69
+ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
70
+ const lines: string[] = [
71
+ `任务 ${t.id} 「${t.title}」`,
72
+ `状态: ${t.status} (v${t.version}) · 紧急度: ${t.urgency} · 项目: ${t.workspaceId}${t.blocked ? ' · 受阻' : ''}`,
73
+ `执行方式: ${t.execution.mode}${t.execution.cron !== undefined ? ` cron=${t.execution.cron}` : ''}`,
74
+ ]
75
+ if (t.execution.nextRunAt !== undefined) lines.push(`下次触发: ${new Date(t.execution.nextRunAt).toISOString()}`)
76
+ if (t.model !== undefined) lines.push(`固定模型: ${t.model.provider}/${t.model.model}`)
77
+ lines.push(`描述: ${t.description.length > 0 ? t.description : '(无)'}`)
78
+ lines.push(`执行 Prompt: ${t.effectivePrompt ?? effectivePrompt(t)}`)
79
+ if (t.comments.length > 0) {
80
+ lines.push(`评论 (${t.comments.length}):`)
81
+ for (const c of t.comments) {
82
+ const who = c.threadId !== undefined ? `agent ${String(c.threadId).slice(0, 24)}` : 'user'
83
+ lines.push(` - [${who} ${new Date(c.createdAt).toISOString()}] ${c.body}`)
84
+ }
85
+ } else {
86
+ lines.push('评论: 无')
87
+ }
88
+ if (t.executions.length > 0) {
89
+ lines.push(`执行记录 (${t.executions.length}):`)
90
+ for (const e of t.executions) {
91
+ const at = e.startedAt !== undefined ? new Date(e.startedAt).toISOString() : '?'
92
+ const err = e.error !== undefined ? ` 错误: ${e.error}` : ''
93
+ lines.push(` - [${e.trigger} ${at}] ${e.outcome}${err}`)
94
+ }
95
+ } else {
96
+ lines.push('执行记录: 无')
97
+ }
98
+ const updatedBy = t.updatedBy.kind === 'agent' ? `agent ${String(t.updatedBy.sessionId).slice(0, 24)}` : 'user'
99
+ lines.push(`更新: ${new Date(t.updatedAt).toISOString()} 由 ${updatedBy}`)
100
+ return lines.join('\n')
101
+ }
102
+
103
+ /** Stable error codes surfaced at the head of tool error messages. */
104
+ export const ERR = {
105
+ notFound: 'not_found',
106
+ versionConflict: 'version_conflict',
107
+ workspaceMismatch: 'workspace_mismatch',
108
+ invalidTransition: 'invalid_transition',
109
+ forbidden: 'forbidden',
110
+ requiresAgent: 'unauthorized_actor',
111
+ invalidInput: 'invalid_input',
112
+ } as const
113
+
114
+ /** Tool failure: an Error whose message starts with a stable code. */
115
+ class ToolError extends Error {
116
+ constructor(readonly code: string, detail: string) {
117
+ super(`Error: ${code}: ${detail}`)
118
+ }
119
+ }
120
+
121
+ /** The workspace face the tools need (narrow for tests). */
122
+ export interface WorkspaceFace {
123
+ /** Resolve the workspace owning a canonical cwd, if any. */
124
+ resolveByPath(path: string): Promise<{ id: string } | undefined>
125
+ /** Get a workspace by id. */
126
+ get(id: string): { id: string; path: string; title: string } | undefined
127
+ /** List all workspaces. */
128
+ list(): Array<{ id: string; path: string; title: string }>
129
+ }
130
+
131
+ /** Adapt the real registry to the narrow face. */
132
+ export function workspaceFace(registry: WorkspaceRegistry): WorkspaceFace {
133
+ // Explicit field mapping: Workspace entities expose path/title as prototype
134
+ // getters, which JSON.stringify skips (own enumerable properties only).
135
+ return {
136
+ resolveByPath: async (path) => {
137
+ const ws = await registry.resolveByPath(path as never)
138
+ return ws === undefined ? undefined : { id: ws.id }
139
+ },
140
+ get: id => {
141
+ const ws = registry.get(id as never)
142
+ return ws === undefined ? undefined : { id: ws.id, path: ws.path, title: ws.title }
143
+ },
144
+ list: () => registry.list().map(ws => ({ id: ws.id, path: ws.path, title: ws.title })),
145
+ }
146
+ }
147
+
148
+ /** Everything the tool set needs. */
149
+ export interface ToolDeps {
150
+ store: TaskStore
151
+ workspaces: WorkspaceFace
152
+ /** Current epoch ms (injectable for tests). */
153
+ now: () => number
154
+ }
155
+
156
+ /** Resolve the calling agent's actor and session id. */
157
+ function caller(exec: ToolRunContext): { actor: Actor & { kind: 'agent' }; sessionId: string } {
158
+ if (!exec.agent) throw new ToolError(ERR.requiresAgent, 'taskboard tools require a calling agent session')
159
+ const sessionId = exec.agent.id
160
+ return { actor: { kind: 'agent', sessionId }, sessionId }
161
+ }
162
+
163
+ /** The calling session's workspace id (undefined when unaffiliated). */
164
+ async function callerWorkspace(deps: ToolDeps, exec: ToolRunContext): Promise<string | undefined> {
165
+ const cwd = exec.agent?.session.header.cwd
166
+ if (typeof cwd !== 'string' || cwd.length === 0) return undefined
167
+ const ws = await deps.workspaces.resolveByPath(cwd)
168
+ return ws?.id
169
+ }
170
+
171
+ /** Guard: version match. */
172
+ function versionGuard(task: TaskRecord, ifVersion: number | undefined): void {
173
+ if (ifVersion === undefined) {
174
+ throw new ToolError(ERR.versionConflict, 'this write requires ifVersion; read the task first')
175
+ }
176
+ if (ifVersion !== task.version) {
177
+ throw new ToolError(ERR.versionConflict, `stale version ${ifVersion} (current ${task.version}); re-read the task and retry once`)
178
+ }
179
+ }
180
+
181
+ /** Re-throw with a stable code; non-ToolErrors become invalid_input. */
182
+ function fail(error: unknown): never {
183
+ if (error instanceof ToolError) throw error
184
+ const message = error instanceof Error ? error.message : String(error)
185
+ throw new ToolError(ERR.invalidInput, message)
186
+ }
187
+
188
+ /** Loose json output schema shared by every taskboard tool. */
189
+ const JSON_OUT = { type: 'json' } as const
190
+
191
+ /** Deep-JSON a value for a json-rooted tool output (spread results lose implicit index signatures). */
192
+ function json<T>(value: T): Record<string, unknown> {
193
+ return JSON.parse(JSON.stringify(value)) as Record<string, unknown>
194
+ }
195
+
196
+ /** The exec context face the tools read (agent identity + session cwd). */
197
+ export interface ToolRunContext {
198
+ agent?: { id: string; session: { header: { cwd?: string } } }
199
+ }
200
+
201
+ /** Registry-like context face (tests stub this). */
202
+ export interface ToolContextFace {
203
+ tools: { register(tool: { name: string }): unknown }
204
+ }
205
+
206
+ /**
207
+ * Register all eight tools.
208
+ * @param ctx - a context exposing `tools.register`.
209
+ * @param deps - store + workspaces + clock.
210
+ * @returns dispose functions, one per tool.
211
+ */
212
+ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Array<() => void> {
213
+ const disposers: Array<() => void> = []
214
+ const { store, workspaces } = deps
215
+
216
+ // Env-gated tool-call tracing (ATB_TRACE=1) — evidence for protocol E2E.
217
+ const register = (tool: { name: string; execute?: unknown }) => {
218
+ if (process.env.ATB_TRACE === '1' && typeof tool.execute === 'function') {
219
+ const orig = tool.execute as (args: unknown, exec: unknown) => Promise<unknown>
220
+ tool.execute = async (args: unknown, exec: unknown) => {
221
+ console.error(`[atb ▶] ${tool.name}`, JSON.stringify(args).slice(0, 300))
222
+ try {
223
+ const result = await orig(args, exec)
224
+ console.error(`[atb ✓] ${tool.name}`, JSON.stringify(result).slice(0, 300))
225
+ return result
226
+ } catch (error) {
227
+ console.error(`[atb ✗] ${tool.name}`, String(error).slice(0, 400))
228
+ throw error
229
+ }
230
+ }
231
+ }
232
+ return ctx.tools.register(tool as { name: string })
233
+ }
234
+
235
+ // ------------------------------------------------------------------ list
236
+ disposers.push(register(defineTool({
237
+ name: 'taskboard_list',
238
+ description:
239
+ 'List task-board tasks. Filter by project (workspaceId), status, or urgency. '
240
+ + 'Returns compact summaries (id, title, status, urgency, version, claim owner). '
241
+ + 'Check this before starting work to find claimable todo tasks in your project.',
242
+ parameters: {
243
+ workspaceId: { type: 'string', description: 'Filter by project (DSH workspace id).' },
244
+ status: { type: 'string', description: 'Filter by exact status (backlog/todo/in_progress/in_review/done/canceled/archived).' },
245
+ urgency: { type: 'string', description: 'Filter by urgency (urgent/normal/relaxed).' },
246
+ includeTrashed: { type: 'boolean', description: 'Include soft-deleted tasks (default false).' },
247
+ },
248
+ output: {
249
+ schema: JSON_OUT,
250
+ render: (_args, value) => {
251
+ const v = value as { revision?: number; tasks?: Array<Record<string, unknown>> }
252
+ const tasks = v.tasks ?? []
253
+ const head = `任务 ${tasks.length} 条(台账 rev ${v.revision ?? '?'})`
254
+ if (tasks.length === 0) return [{ type: 'text', text: `${head}:无匹配任务。` }]
255
+ return [{ type: 'text', text: [head, ...tasks.map(t => taskLine(t as never))].join('\n') }]
256
+ },
257
+ },
258
+ async execute(args) {
259
+ try {
260
+ const a = args as { workspaceId?: string; status?: string; urgency?: string; includeTrashed?: boolean }
261
+ const tasks = store.snapshot().tasks.filter(t =>
262
+ (a.workspaceId === undefined || t.workspaceId === a.workspaceId)
263
+ && (a.status === undefined || t.status === a.status)
264
+ && (a.urgency === undefined || t.urgency === a.urgency)
265
+ && (a.includeTrashed === true || t.trashedAt === undefined))
266
+ return json({ revision: store.snapshot().revision, tasks: tasks.map(summarize) })
267
+ } catch (error) { fail(error) }
268
+ },
269
+ })) as () => void)
270
+
271
+ // ------------------------------------------------------------------- get
272
+ disposers.push(register(defineTool({
273
+ name: 'taskboard_get',
274
+ description:
275
+ 'Read one task in full: description, prompt, project, urgency, status, comments, executions, version. '
276
+ + 'Read this (and the comments) BEFORE claiming or starting work on a task.',
277
+ parameters: {
278
+ id: { type: 'string', required: true, description: 'Task id from the board.' },
279
+ },
280
+ output: {
281
+ schema: JSON_OUT,
282
+ render: (_args, value) => {
283
+ const v = value as { task?: TaskRecord & { effectivePrompt?: string } }
284
+ return [{ type: 'text', text: v.task === undefined ? '任务不存在。' : taskDetail(v.task) }]
285
+ },
286
+ },
287
+ async execute(args: { id: string }) {
288
+ try {
289
+ const { id } = args
290
+ const task = store.get(id)
291
+ if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${id}`)
292
+ return json({ task: { ...task, effectivePrompt: effectivePrompt(task) } })
293
+ } catch (error) { fail(error) }
294
+ },
295
+ })) as () => void)
296
+
297
+ // ---------------------------------------------------------------- create
298
+ disposers.push(register(defineTool({
299
+ name: 'taskboard_create',
300
+ description:
301
+ 'Create a task on the board. Required: title, workspaceId (project), urgency (urgent/normal/relaxed). '
302
+ + 'Optional: description, prompt (sent to a fresh session on execution), status (default todo), '
303
+ + 'execution mode (claim|scheduled + cron), model {provider, model} to pin executions to a model. '
304
+ + 'Do not track trivial requests as tasks.',
305
+ parameters: {
306
+ title: { type: 'string', required: true, description: 'Short imperative line (1..200 chars).' },
307
+ workspaceId: { type: 'string', required: true, description: 'Project (DSH workspace id) this task belongs to.' },
308
+ urgency: { type: 'string', required: true, description: 'urgent (red) | normal (purple) | relaxed (blue).' },
309
+ description: { type: 'string', description: 'What the task involves (plain text).' },
310
+ prompt: { type: 'string', description: 'Prompt sent to a fresh session when executed; default = title+description.' },
311
+ status: { type: 'string', description: 'Initial status; default todo. backlog = not approved for execution.' },
312
+ execution: {
313
+ type: 'object',
314
+ additionalProperties: false,
315
+ description: 'Execution config: { mode: "claim" } (default) or { mode: "scheduled", cron: "m h dom mon dow" }.',
316
+ properties: {
317
+ mode: { type: 'string', description: 'claim | scheduled.' },
318
+ cron: { type: 'string', description: 'Five-field cron expression (scheduled only).' },
319
+ },
320
+ },
321
+ model: {
322
+ type: 'object',
323
+ additionalProperties: false,
324
+ description: 'Pin executions to one configured model: { provider, model }. Omit to use the default model.',
325
+ properties: {
326
+ provider: { type: 'string', description: 'Provider route id.' },
327
+ model: { type: 'string', description: 'Provider-owned model id.' },
328
+ },
329
+ },
330
+ },
331
+ output: {
332
+ schema: JSON_OUT,
333
+ render: (_args, value) => {
334
+ const v = value as { task?: { id?: string; status?: string; version?: number } }
335
+ const t = v.task
336
+ return [{ type: 'text', text: t === undefined ? '创建失败。' : `已创建任务 ${t.id} [${t.status}] v${t.version}。写入前先 taskboard_get 读取。` }]
337
+ },
338
+ },
339
+ async execute(args: {
340
+ title: string
341
+ workspaceId: string
342
+ urgency: string
343
+ status?: string
344
+ description?: string
345
+ prompt?: string
346
+ execution?: { mode?: string; cron?: string }
347
+ model?: { provider?: string; model?: string }
348
+ }, exec: unknown) {
349
+ try {
350
+ const { actor } = caller(exec as ToolRunContext)
351
+ const title = normalizeTitle(args.title)
352
+ if (workspaces.get(args.workspaceId) === undefined) {
353
+ throw new ToolError(ERR.notFound, `unknown workspaceId ${args.workspaceId}`)
354
+ }
355
+ const urgency = asUrgency(args.urgency)
356
+ const status = args.status === undefined ? 'todo' as const : asStatus(args.status)
357
+ if (status === 'done' || status === 'archived') {
358
+ throw new ToolError(ERR.invalidTransition, 'a new task cannot start as done/archived')
359
+ }
360
+ const execution = normalizeExecution(args.execution ?? {}, deps.now())
361
+ if (args.model !== undefined && (typeof args.model.provider !== 'string' || typeof args.model.model !== 'string')) {
362
+ throw new ToolError(ERR.invalidInput, 'model must be { provider: string, model: string }')
363
+ }
364
+ const now = deps.now()
365
+ const task: TaskRecord = {
366
+ id: newTaskId(),
367
+ title,
368
+ description: (args.description ?? '').trim(),
369
+ prompt: normalizePrompt(args.prompt),
370
+ workspaceId: args.workspaceId,
371
+ urgency,
372
+ status,
373
+ blocked: false,
374
+ execution,
375
+ model: args.model as TaskModel | undefined,
376
+ version: 1,
377
+ createdAt: now,
378
+ updatedAt: now,
379
+ createdBy: actor,
380
+ updatedBy: actor,
381
+ comments: [],
382
+ executions: [],
383
+ }
384
+ await store.mutate('task-created', ledger => {
385
+ ledger.tasks.push(task)
386
+ return [task]
387
+ })
388
+ return json({ task: summarize(task) })
389
+ } catch (error) { fail(error) }
390
+ },
391
+ })) as () => void)
392
+
393
+ // ---------------------------------------------------------------- update
394
+ disposers.push(register(defineTool({
395
+ name: 'taskboard_update',
396
+ description:
397
+ 'Update a task\'s title/description/prompt/urgency/blocked. Requires ifVersion (read first). '
398
+ + 'The model and execution config are read-only through this tool (they belong to the task owner/user).',
399
+ parameters: {
400
+ id: { type: 'string', required: true, description: 'Task id.' },
401
+ ifVersion: { type: 'number', required: true, description: 'The task version you read; the write fails on mismatch.' },
402
+ title: { type: 'string', description: 'New title.' },
403
+ description: { type: 'string', description: 'New description.' },
404
+ prompt: { type: 'string', description: 'New execution prompt.' },
405
+ urgency: { type: 'string', description: 'urgent | normal | relaxed.' },
406
+ blocked: { type: 'boolean', description: 'Blocked marker (work cannot continue right now).' },
407
+ },
408
+ output: {
409
+ schema: JSON_OUT,
410
+ render: (_args, value) => {
411
+ const v = value as { task?: { id?: string; status?: string; version?: number } }
412
+ const t = v.task
413
+ return [{ type: 'text', text: t === undefined ? '更新失败。' : `已更新任务 ${t.id},当前 v${t.version} [${t.status}]。` }]
414
+ },
415
+ },
416
+ async execute(args: {
417
+ id: string
418
+ ifVersion: number
419
+ title?: string
420
+ description?: string
421
+ prompt?: string
422
+ urgency?: string
423
+ blocked?: boolean
424
+ }, exec: unknown) {
425
+ try {
426
+ const { actor } = caller(exec as ToolRunContext)
427
+ const task = store.get(args.id)
428
+ if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
429
+ versionGuard(task, args.ifVersion)
430
+ if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
431
+ const next: TaskRecord = structuredClone(task)
432
+ if (args.title !== undefined) next.title = normalizeTitle(args.title)
433
+ if (args.description !== undefined) next.description = args.description.trim()
434
+ if (args.prompt !== undefined) next.prompt = normalizePrompt(args.prompt)
435
+ if (args.urgency !== undefined) next.urgency = asUrgency(args.urgency)
436
+ if (args.blocked !== undefined) next.blocked = args.blocked
437
+ next.version = task.version + 1
438
+ next.updatedAt = deps.now()
439
+ next.updatedBy = actor
440
+ await store.mutate('task-updated', ledger => {
441
+ const i = ledger.tasks.findIndex(t => t.id === args.id)
442
+ ledger.tasks[i] = next
443
+ return [next]
444
+ })
445
+ return json({ task: summarize(next) })
446
+ } catch (error) { fail(error) }
447
+ },
448
+ })) as () => void)
449
+
450
+ // ------------------------------------------------------------------ move
451
+ disposers.push(register(defineTool({
452
+ name: 'taskboard_move',
453
+ description:
454
+ 'Move a task between statuses (requires ifVersion). Claim = todo→in_progress (only a session '
455
+ + 'inside the task\'s project may claim; never take over a task held by another session). '
456
+ + 'After implementing and self-verifying: comment, then in_progress→in_review. '
457
+ + 'You can NEVER move a task to done — that requires explicit user confirmation.',
458
+ parameters: {
459
+ id: { type: 'string', required: true, description: 'Task id.' },
460
+ status: { type: 'string', required: true, description: 'Target status.' },
461
+ ifVersion: { type: 'number', required: true, description: 'Task version you read; fails on mismatch.' },
462
+ },
463
+ output: {
464
+ schema: JSON_OUT,
465
+ render: (_args, value) => {
466
+ const v = value as { task?: { id?: string; status?: string; version?: number } }
467
+ const t = v.task
468
+ return [{ type: 'text', text: t === undefined ? '移动失败。' : `任务 ${t.id} 已移到 ${t.status},当前 v${t.version}。` }]
469
+ },
470
+ },
471
+ async execute(args: { id: string; status: string; ifVersion: number }, exec: unknown) {
472
+ try {
473
+ const { actor } = caller(exec as ToolRunContext)
474
+ const to = asStatus(args.status)
475
+ const task = store.get(args.id)
476
+ if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
477
+ versionGuard(task, args.ifVersion)
478
+
479
+ // Code-level gate: agents never complete a task.
480
+ if (to === 'done') {
481
+ throw new ToolError(ERR.forbidden, 'moving a task to done requires explicit user confirmation (GUI); agents cannot do it')
482
+ }
483
+ if (!canTransition(task.status, to)) {
484
+ throw new ToolError(ERR.invalidTransition, `illegal transition ${task.status} → ${to}`)
485
+ }
486
+ // Exclusive hold: while a task is in_progress under an agent, no other
487
+ // session may move it at all (that would be a takeover).
488
+ if (task.status === 'in_progress' && task.updatedBy.kind === 'agent' && task.updatedBy.sessionId !== actor.sessionId) {
489
+ throw new ToolError(ERR.forbidden, `task is held by session ${task.updatedBy.sessionId}; never take over another session's claim`)
490
+ }
491
+ // Claim boundary: the calling session must belong to the task's project.
492
+ if (isClaim(task.status, to)) {
493
+ const wsId = await callerWorkspace(deps, exec as ToolRunContext)
494
+ if (wsId !== task.workspaceId) {
495
+ throw new ToolError(ERR.workspaceMismatch, 'only a session inside this task\'s project may claim it')
496
+ }
497
+ }
498
+ const next: TaskRecord = structuredClone(task)
499
+ next.status = to
500
+ next.version = task.version + 1
501
+ next.updatedAt = deps.now()
502
+ next.updatedBy = actor
503
+ if (isClaim(task.status, to)) next.blocked = false
504
+ await store.mutate('task-moved', ledger => {
505
+ const i = ledger.tasks.findIndex(t => t.id === args.id)
506
+ ledger.tasks[i] = next
507
+ return [next]
508
+ })
509
+ return json({ task: summarize(next) })
510
+ } catch (error) { fail(error) }
511
+ },
512
+ })) as () => void)
513
+
514
+ // ----------------------------------------------------------- comment_add
515
+ disposers.push(register(defineTool({
516
+ name: 'taskboard_comment_add',
517
+ description:
518
+ 'Append a progress/report comment to a task. When handing off to review, the comment should cover: '
519
+ + 'what changed, how it was verified, outcome, and remaining risks.',
520
+ parameters: {
521
+ id: { type: 'string', required: true, description: 'Task id.' },
522
+ body: { type: 'string', required: true, description: 'Comment text (1..4000 chars).' },
523
+ },
524
+ output: {
525
+ schema: JSON_OUT,
526
+ render: (_args, value) => {
527
+ const v = value as { comment?: { id?: string }; task?: { id?: string; version?: number; status?: string } }
528
+ const c = v.comment
529
+ const t = v.task
530
+ if (c === undefined || t === undefined) return [{ type: 'text', text: '评论失败。' }]
531
+ // The comment bumped the version — echo it so the agent can chain the
532
+ // next write (e.g. move → in_review) WITHOUT re-reading.
533
+ return [{
534
+ type: 'text',
535
+ text: `评论 ${c.id} 已添加;任务 ${t.id} 当前 v${t.version} [${t.status}](后续写操作用此版本号).`,
536
+ }]
537
+ },
538
+ },
539
+ async execute(args: { id: string; body: string }, exec: unknown) {
540
+ try {
541
+ const { sessionId } = caller(exec as ToolRunContext)
542
+ const task = store.get(args.id)
543
+ if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
544
+ const comment = {
545
+ id: newCommentId(),
546
+ body: normalizeBody(args.body),
547
+ version: 1,
548
+ createdAt: deps.now(),
549
+ threadId: sessionId,
550
+ }
551
+ const next: TaskRecord = structuredClone(task)
552
+ next.comments.push(comment)
553
+ next.version = task.version + 1
554
+ next.updatedAt = deps.now()
555
+ await store.mutate('comment-added', ledger => {
556
+ const i = ledger.tasks.findIndex(t => t.id === args.id)
557
+ ledger.tasks[i] = next
558
+ return [next]
559
+ })
560
+ return json({ comment, task: { id: next.id, version: next.version, status: next.status } })
561
+ } catch (error) { fail(error) }
562
+ },
563
+ })) as () => void)
564
+
565
+ // -------------------------------------------------------------- comments
566
+ disposers.push(register(defineTool({
567
+ name: 'taskboard_comments',
568
+ description: 'List a task\'s comments, oldest first. Read them before deciding to start work.',
569
+ parameters: {
570
+ id: { type: 'string', required: true, description: 'Task id.' },
571
+ },
572
+ output: {
573
+ schema: JSON_OUT,
574
+ render: (_args, value) => {
575
+ const v = value as { comments?: unknown[] }
576
+ const list = v.comments as Array<{ body: string; createdAt: number; threadId?: string }> | undefined
577
+ if (list === undefined || list.length === 0) return [{ type: 'text', text: '无评论。' }]
578
+ const lines = list.map(c => {
579
+ const who = c.threadId !== undefined ? `agent ${String(c.threadId).slice(0, 24)}` : 'user'
580
+ return `- [${who} ${new Date(c.createdAt).toISOString()}] ${c.body}`
581
+ })
582
+ return [{ type: 'text', text: `评论 ${list.length} 条:\n${lines.join('\n')}` }]
583
+ },
584
+ },
585
+ async execute(args: { id: string }) {
586
+ try {
587
+ const task = store.get(args.id)
588
+ if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
589
+ return json({ comments: task.comments })
590
+ } catch (error) { fail(error) }
591
+ },
592
+ })) as () => void)
593
+
594
+ // ---------------------------------------------------------------- delete
595
+ disposers.push(register(defineTool({
596
+ name: 'taskboard_delete',
597
+ description:
598
+ 'Soft-delete a task (marks it trashed; the user confirms the purge in the GUI). '
599
+ + 'Requires ifVersion. Prefer canceled/archived over delete unless the task was a mistake.',
600
+ parameters: {
601
+ id: { type: 'string', required: true, description: 'Task id.' },
602
+ ifVersion: { type: 'number', required: true, description: 'Task version you read.' },
603
+ },
604
+ output: {
605
+ schema: JSON_OUT,
606
+ render: (_args, value) => {
607
+ const v = value as { trashed?: boolean }
608
+ return [{ type: 'text', text: v.trashed === true ? '任务已标记删除(等待用户在 GUI 清除)。' : '删除失败。' }]
609
+ },
610
+ },
611
+ async execute(args: { id: string; ifVersion: number }, exec: unknown) {
612
+ try {
613
+ caller(exec as ToolRunContext)
614
+ const task = store.get(args.id)
615
+ if (task === undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
616
+ versionGuard(task, args.ifVersion)
617
+ const next: TaskRecord = structuredClone(task)
618
+ next.trashedAt = deps.now()
619
+ next.version = task.version + 1
620
+ await store.mutate('task-deleted', ledger => {
621
+ const i = ledger.tasks.findIndex(t => t.id === args.id)
622
+ ledger.tasks[i] = next
623
+ return [next]
624
+ })
625
+ return { trashed: true }
626
+ } catch (error) { fail(error) }
627
+ },
628
+ })) as () => void)
629
+
630
+ return disposers
631
+ }