@swarmclawai/swarmclaw 1.9.9 → 1.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -399,6 +399,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.9.10 Highlights
403
+
404
+ Task handoff release: operators can package task state, readiness, workspace context, dependencies, outputs, and resume handles into a shareable packet before continuing work.
405
+
406
+ - **Task handoff packets.** `GET /api/tasks/:id/handoff` returns a structured packet with owner, liveness, workspace, runtime links, dependencies, quality checks, outputs, run summary, and recommended actions.
407
+ - **Workspace snapshots.** `POST /api/tasks/:id/handoff` prepares a workspace when needed and writes `handoff.md` plus `handoff.json` beside the task context files.
408
+ - **Board-level triage.** `GET /api/tasks/handoffs` lists readiness packets with ready, needs-attention, and blocked counts so operators can scan handoff risk across the board.
409
+ - **CLI and UI access.** `swarmclaw tasks handoff`, `swarmclaw tasks handoff-save`, and `swarmclaw tasks handoffs` expose the workflow for scripts, while the task sheet can copy, open, or save packets.
410
+
402
411
  ### v1.9.9 Highlights
403
412
 
404
413
  Schedule revision timeline release: schedule edits, lifecycle changes, and run evidence now stay inspectable from UI, API, and CLI surfaces.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.9",
3
+ "version": "1.9.10",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -87,7 +87,7 @@
87
87
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
88
88
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
89
89
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
90
- "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/quality/release-readiness.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
90
+ "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/quality/release-readiness.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
91
91
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
92
92
  "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
93
93
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -0,0 +1,73 @@
1
+ import { NextResponse } from 'next/server'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import { buildTaskHandoffPacket, formatTaskHandoffMarkdown } from '@/lib/server/tasks/task-handoff'
5
+ import { prepareTaskExecutionWorkspace } from '@/lib/server/tasks/task-execution-workspace'
6
+ import { loadTasks, saveTask } from '@/lib/server/tasks/task-repository'
7
+
8
+ export const dynamic = 'force-dynamic'
9
+
10
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
11
+ const { id } = await params
12
+ const tasks = loadTasks()
13
+ const task = tasks[id]
14
+ if (!task) return NextResponse.json({ error: 'Task not found' }, { status: 404 })
15
+
16
+ const packet = buildTaskHandoffPacket(task, tasks)
17
+ const { searchParams } = new URL(req.url)
18
+ if (searchParams.get('format') === 'markdown') {
19
+ return new Response(formatTaskHandoffMarkdown(packet), {
20
+ headers: {
21
+ 'content-type': 'text/markdown; charset=utf-8',
22
+ },
23
+ })
24
+ }
25
+
26
+ return NextResponse.json(packet)
27
+ }
28
+
29
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
30
+ const { id } = await params
31
+ const tasks = loadTasks()
32
+ const task = tasks[id]
33
+ if (!task) return NextResponse.json({ error: 'Task not found' }, { status: 404 })
34
+
35
+ let body: Record<string, unknown> = {}
36
+ try {
37
+ body = await req.json() as Record<string, unknown>
38
+ } catch {
39
+ body = {}
40
+ }
41
+
42
+ if (!task.executionWorkspace || body.prepareWorkspace !== false) {
43
+ Object.assign(task, prepareTaskExecutionWorkspace(task, {
44
+ now: Date.now(),
45
+ actor: 'user',
46
+ tasks,
47
+ }))
48
+ task.updatedAt = Date.now()
49
+ tasks[id] = task
50
+ saveTask(id, task)
51
+ }
52
+
53
+ const workspacePath = task.executionWorkspace?.path
54
+ if (!workspacePath) {
55
+ return NextResponse.json({ error: 'Task workspace is not available' }, { status: 409 })
56
+ }
57
+
58
+ fs.mkdirSync(workspacePath, { recursive: true })
59
+ const packet = buildTaskHandoffPacket(task, tasks)
60
+ const markdown = formatTaskHandoffMarkdown(packet)
61
+ const markdownPath = path.join(workspacePath, 'handoff.md')
62
+ const jsonPath = path.join(workspacePath, 'handoff.json')
63
+ fs.writeFileSync(markdownPath, markdown, 'utf8')
64
+ fs.writeFileSync(jsonPath, `${JSON.stringify(packet, null, 2)}\n`, 'utf8')
65
+
66
+ return NextResponse.json({
67
+ packet,
68
+ files: {
69
+ markdownPath,
70
+ jsonPath,
71
+ },
72
+ })
73
+ }
@@ -0,0 +1,50 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { buildTaskHandoffPacket } from '@/lib/server/tasks/task-handoff'
3
+ import { loadTasks } from '@/lib/server/tasks/task-repository'
4
+ import type { TaskHandoffReadinessStatus } from '@/types'
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ const READINESS_STATUSES: TaskHandoffReadinessStatus[] = ['ready', 'needs_attention', 'blocked']
9
+
10
+ function normalizeLimit(value: string | null): number {
11
+ const parsed = value ? Number.parseInt(value, 10) : 50
12
+ if (!Number.isFinite(parsed)) return 50
13
+ return Math.max(1, Math.min(200, Math.trunc(parsed)))
14
+ }
15
+
16
+ export async function GET(req: Request) {
17
+ const { searchParams } = new URL(req.url)
18
+ const status = searchParams.get('status') as TaskHandoffReadinessStatus | null
19
+ const includeArchived = searchParams.get('includeArchived') === 'true'
20
+ const limit = normalizeLimit(searchParams.get('limit'))
21
+ const now = Date.now()
22
+ const tasks = loadTasks()
23
+ const packets = Object.values(tasks)
24
+ .filter((task) => includeArchived || task.status !== 'archived')
25
+ .map((task) => buildTaskHandoffPacket(task, tasks, { now, runBrief: null }))
26
+ .sort((left, right) => {
27
+ const statusRank: Record<TaskHandoffReadinessStatus, number> = {
28
+ blocked: 0,
29
+ needs_attention: 1,
30
+ ready: 2,
31
+ }
32
+ return statusRank[left.readiness.status] - statusRank[right.readiness.status] || right.updatedAt - left.updatedAt
33
+ })
34
+
35
+ const filtered = status && READINESS_STATUSES.includes(status)
36
+ ? packets.filter((packet) => packet.readiness.status === status)
37
+ : packets
38
+ const counts: Record<TaskHandoffReadinessStatus, number> = {
39
+ ready: 0,
40
+ needs_attention: 0,
41
+ blocked: 0,
42
+ }
43
+ for (const packet of packets) counts[packet.readiness.status] += 1
44
+
45
+ return NextResponse.json({
46
+ generatedAt: now,
47
+ counts,
48
+ items: filtered.slice(0, limit),
49
+ })
50
+ }
@@ -15,6 +15,9 @@ const originalEnv = {
15
15
 
16
16
  let tempDir = ''
17
17
  let putTask: typeof import('./[id]/route')['PUT']
18
+ let getTaskHandoff: typeof import('./[id]/handoff/route')['GET']
19
+ let postTaskHandoff: typeof import('./[id]/handoff/route')['POST']
20
+ let getTaskHandoffs: typeof import('./handoffs/route')['GET']
18
21
  let getTasks: typeof import('./route')['GET']
19
22
  let storage: typeof import('@/lib/server/storage')
20
23
 
@@ -46,6 +49,10 @@ before(async () => {
46
49
  process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
47
50
  storage = await import('@/lib/server/storage')
48
51
  putTask = (await import('./[id]/route')).PUT
52
+ const handoffRoute = await import('./[id]/handoff/route')
53
+ getTaskHandoff = handoffRoute.GET
54
+ postTaskHandoff = handoffRoute.POST
55
+ getTaskHandoffs = (await import('./handoffs/route')).GET
49
56
  getTasks = (await import('./route')).GET
50
57
  })
51
58
 
@@ -114,3 +121,94 @@ test('GET /api/tasks returns computed blocked liveness without persisting a task
114
121
  assert.equal(body['task-blocked']?.liveness?.state, 'blocked')
115
122
  assert.deepEqual(body['task-blocked']?.liveness?.blockerTaskIds, ['dep-route'])
116
123
  })
124
+
125
+ test('GET /api/tasks/:id/handoff returns readiness and markdown packets', async () => {
126
+ seedTask('task-handoff', {
127
+ title: 'Handoff Route Task',
128
+ description: 'Prepare a packet.',
129
+ blockedBy: ['dep-handoff'],
130
+ qualityGate: {
131
+ enabled: true,
132
+ minResultChars: 50,
133
+ minEvidenceItems: 1,
134
+ },
135
+ })
136
+ const tasks = storage.loadTasks()
137
+ tasks['dep-handoff'] = {
138
+ id: 'dep-handoff',
139
+ title: 'Dependency',
140
+ description: '',
141
+ status: 'running',
142
+ agentId: 'agent-1',
143
+ createdAt: Date.now(),
144
+ updatedAt: Date.now(),
145
+ } as BoardTask
146
+ storage.saveTasks(tasks)
147
+
148
+ const jsonResponse = await getTaskHandoff(
149
+ new Request('http://local/api/tasks/task-handoff/handoff'),
150
+ routeParams('task-handoff'),
151
+ )
152
+ assert.equal(jsonResponse.status, 200)
153
+ const packet = await jsonResponse.json()
154
+ assert.equal(packet.taskId, 'task-handoff')
155
+ assert.equal(packet.readiness.status, 'blocked')
156
+ assert.equal(packet.dependencies.blockedBy[0]?.id, 'dep-handoff')
157
+
158
+ const markdownResponse = await getTaskHandoff(
159
+ new Request('http://local/api/tasks/task-handoff/handoff?format=markdown'),
160
+ routeParams('task-handoff'),
161
+ )
162
+ assert.equal(markdownResponse.status, 200)
163
+ assert.match(markdownResponse.headers.get('content-type') || '', /text\/markdown/)
164
+ const markdown = await markdownResponse.text()
165
+ assert.match(markdown, /# Task Handoff: Handoff Route Task/)
166
+ assert.match(markdown, /Readiness: blocked/)
167
+ })
168
+
169
+ test('POST /api/tasks/:id/handoff saves markdown and JSON snapshots into the workspace', async () => {
170
+ seedTask('task-handoff-save', {
171
+ title: 'Saved Handoff Task',
172
+ cwd: '/source/repo',
173
+ result: 'Ready for the next operator.',
174
+ })
175
+
176
+ const response = await postTaskHandoff(
177
+ new Request('http://local/api/tasks/task-handoff-save/handoff', {
178
+ method: 'POST',
179
+ headers: { 'content-type': 'application/json' },
180
+ body: JSON.stringify({ prepareWorkspace: true }),
181
+ }),
182
+ routeParams('task-handoff-save'),
183
+ )
184
+
185
+ assert.equal(response.status, 200)
186
+ const body = await response.json()
187
+ assert.equal(body.packet.taskId, 'task-handoff-save')
188
+ assert.equal(fs.existsSync(body.files.markdownPath), true)
189
+ assert.equal(fs.existsSync(body.files.jsonPath), true)
190
+ assert.match(fs.readFileSync(body.files.markdownPath, 'utf8'), /# Task Handoff: Saved Handoff Task/)
191
+ })
192
+
193
+ test('GET /api/tasks/handoffs lists board-level readiness packets with counts', async () => {
194
+ seedTask('task-ready', {
195
+ title: 'Ready Task',
196
+ executionWorkspace: {
197
+ path: '/tmp/ready',
198
+ mode: 'task',
199
+ preparedAt: Date.now(),
200
+ previewLinks: [],
201
+ runtimeServices: [],
202
+ },
203
+ })
204
+ seedTask('task-needs-attention', {
205
+ title: 'Needs Workspace',
206
+ })
207
+
208
+ const response = await getTaskHandoffs(new Request('http://local/api/tasks/handoffs?status=needs_attention&limit=10'))
209
+ assert.equal(response.status, 200)
210
+ const body = await response.json()
211
+ assert.equal(body.counts.ready >= 1, true)
212
+ assert.equal(body.counts.needs_attention >= 1, true)
213
+ assert.equal(body.items.every((packet: { readiness: { status: string } }) => packet.readiness.status === 'needs_attention'), true)
214
+ })
package/src/cli/index.js CHANGED
@@ -735,6 +735,9 @@ const COMMAND_GROUPS = [
735
735
  commands: [
736
736
  cmd('list', 'GET', '/tasks', 'List tasks'),
737
737
  cmd('get', 'GET', '/tasks/:id', 'Get task'),
738
+ cmd('handoff', 'GET', '/tasks/:id/handoff', 'Get task handoff packet'),
739
+ cmd('handoff-save', 'POST', '/tasks/:id/handoff', 'Save task handoff packet into the task workspace', { expectsJsonBody: true }),
740
+ cmd('handoffs', 'GET', '/tasks/handoffs', 'List task handoff readiness packets'),
738
741
  cmd('create', 'POST', '/tasks', 'Create task', { expectsJsonBody: true }),
739
742
  cmd('bulk', 'POST', '/tasks/bulk', 'Bulk update tasks (status/agent/project)', { expectsJsonBody: true }),
740
743
  cmd('update', 'PUT', '/tasks/:id', 'Update task', { expectsJsonBody: true }),
@@ -163,6 +163,38 @@ test('runCli sends authenticated request and emits compact JSON when --json is s
163
163
  assert.equal(stderr.toString(), '')
164
164
  })
165
165
 
166
+ test('tasks handoff command can request markdown packets', async () => {
167
+ const stdout = makeWritable()
168
+ const stderr = makeWritable()
169
+ const calls = []
170
+
171
+ const fetchImpl = async (url, init) => {
172
+ calls.push({ url: String(url), init })
173
+ return new Response('# Task Handoff\n', {
174
+ status: 200,
175
+ headers: { 'content-type': 'text/markdown; charset=utf-8' },
176
+ })
177
+ }
178
+
179
+ const exitCode = await runCli(
180
+ ['tasks', 'handoff', 'task-1', '--query', 'format=markdown'],
181
+ {
182
+ fetchImpl,
183
+ stdout,
184
+ stderr,
185
+ env: {},
186
+ cwd: process.cwd(),
187
+ }
188
+ )
189
+
190
+ assert.equal(exitCode, 0)
191
+ assert.equal(calls.length, 1)
192
+ assert.match(calls[0].url, /\/api\/tasks\/task-1\/handoff\?format=markdown$/)
193
+ assert.equal(calls[0].init.method, 'GET')
194
+ assert.equal(stdout.toString(), '# Task Handoff\n')
195
+ assert.equal(stderr.toString(), '')
196
+ })
197
+
166
198
  test('openclaw deploy bundle command merges action with provided JSON body', async () => {
167
199
  const stdout = makeWritable()
168
200
  const stderr = makeWritable()
package/src/cli/spec.js CHANGED
@@ -529,6 +529,9 @@ const COMMAND_GROUPS = {
529
529
  commands: {
530
530
  list: { description: 'List tasks', method: 'GET', path: '/tasks' },
531
531
  get: { description: 'Get task by id', method: 'GET', path: '/tasks/:id', params: ['id'] },
532
+ handoff: { description: 'Get task handoff packet', method: 'GET', path: '/tasks/:id/handoff', params: ['id'] },
533
+ 'handoff-save': { description: 'Save task handoff packet into the task workspace', method: 'POST', path: '/tasks/:id/handoff', params: ['id'] },
534
+ handoffs: { description: 'List task handoff readiness packets', method: 'GET', path: '/tasks/handoffs' },
532
535
  create: { description: 'Create task', method: 'POST', path: '/tasks' },
533
536
  bulk: { description: 'Bulk update tasks (status/agent/project)', method: 'POST', path: '/tasks/bulk' },
534
537
  update: { description: 'Update task', method: 'PUT', path: '/tasks/:id', params: ['id'] },
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { useRouter } from 'next/navigation'
5
- import { Activity, ExternalLink, FolderOpen, PlayCircle } from 'lucide-react'
5
+ import { Activity, ClipboardCopy, ExternalLink, FileText, FolderOpen, PlayCircle, Save } from 'lucide-react'
6
6
  import ReactMarkdown from 'react-markdown'
7
7
  import remarkGfm from 'remark-gfm'
8
8
  import { useAppStore } from '@/stores/use-app-store'
@@ -27,6 +27,7 @@ import { dedup, errorMessage } from '@/lib/shared-utils'
27
27
  import { SectionLabel } from '@/components/shared/section-label'
28
28
  import { AgentAvatar } from '@/components/agents/agent-avatar'
29
29
  import { InfoChip } from '@/components/ui/info-chip'
30
+ import { fetchTaskHandoffMarkdown, saveTaskHandoffSnapshot } from '@/lib/tasks'
30
31
 
31
32
  function fmtTime(ts: number) {
32
33
  const d = new Date(ts)
@@ -105,6 +106,11 @@ export function TaskSheet() {
105
106
  const [qualityGateRequireReport, setQualityGateRequireReport] = useState(false)
106
107
  const [provisionWorkspace, setProvisionWorkspace] = useState(false)
107
108
  const [workspacePreparing, setWorkspacePreparing] = useState(false)
109
+ const [handoffCopying, setHandoffCopying] = useState(false)
110
+ const [handoffSaving, setHandoffSaving] = useState(false)
111
+ const [handoffCopied, setHandoffCopied] = useState(false)
112
+ const [handoffError, setHandoffError] = useState<string | null>(null)
113
+ const [handoffSavedPath, setHandoffSavedPath] = useState<string | null>(null)
108
114
  const [structuredSessionOpen, setStructuredSessionOpen] = useState(false)
109
115
  const formInitRef = useRef<string | null>(null)
110
116
 
@@ -160,6 +166,9 @@ export function TaskSheet() {
160
166
  setQualityGateRequireArtifact(gate?.requireArtifact ?? defaultGateRequireArtifact)
161
167
  setQualityGateRequireReport(gate?.requireReport ?? defaultGateRequireReport)
162
168
  setProvisionWorkspace(false)
169
+ setHandoffCopied(false)
170
+ setHandoffError(null)
171
+ setHandoffSavedPath(null)
163
172
  formInitRef.current = initKey
164
173
  return
165
174
  }
@@ -185,6 +194,9 @@ export function TaskSheet() {
185
194
  setQualityGateRequireArtifact(defaultGateRequireArtifact)
186
195
  setQualityGateRequireReport(defaultGateRequireReport)
187
196
  setProvisionWorkspace(false)
197
+ setHandoffCopied(false)
198
+ setHandoffError(null)
199
+ setHandoffSavedPath(null)
188
200
  formInitRef.current = initKey
189
201
  }, [
190
202
  activeProjectFilter,
@@ -209,6 +221,9 @@ export function TaskSheet() {
209
221
  const onClose = () => {
210
222
  formInitRef.current = null
211
223
  setDepError(null)
224
+ setHandoffCopied(false)
225
+ setHandoffError(null)
226
+ setHandoffSavedPath(null)
212
227
  setOpen(false)
213
228
  setEditingId(null)
214
229
  }
@@ -299,6 +314,39 @@ export function TaskSheet() {
299
314
  }
300
315
  }
301
316
 
317
+ const handleCopyHandoff = async () => {
318
+ if (!editing) return
319
+ setHandoffCopying(true)
320
+ try {
321
+ const markdown = await fetchTaskHandoffMarkdown(editing.id)
322
+ if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
323
+ throw new Error('Clipboard is unavailable in this browser')
324
+ }
325
+ await navigator.clipboard.writeText(markdown)
326
+ setHandoffCopied(true)
327
+ setHandoffError(null)
328
+ globalThis.setTimeout(() => setHandoffCopied(false), 1800)
329
+ } catch (err: unknown) {
330
+ setHandoffError(errorMessage(err))
331
+ } finally {
332
+ setHandoffCopying(false)
333
+ }
334
+ }
335
+
336
+ const handleSaveHandoff = async () => {
337
+ if (!editing) return
338
+ setHandoffSaving(true)
339
+ try {
340
+ const result = await saveTaskHandoffSnapshot(editing.id, { prepareWorkspace: true })
341
+ setHandoffSavedPath(result.files.markdownPath)
342
+ setHandoffError(null)
343
+ } catch (err: unknown) {
344
+ setHandoffError(errorMessage(err))
345
+ } finally {
346
+ setHandoffSaving(false)
347
+ }
348
+ }
349
+
302
350
  const handleUnarchive = async () => {
303
351
  if (editing) {
304
352
  await updateTaskMutation.mutateAsync({ id: editing.id, patch: { status: 'backlog' } })
@@ -353,6 +401,48 @@ export function TaskSheet() {
353
401
  ? editing.runtimeServices
354
402
  : editing.executionWorkspace?.runtimeServices || [])
355
403
  : []
404
+ const handoffUrl = editing
405
+ ? `/api/tasks/${encodeURIComponent(editing.id)}/handoff?format=markdown`
406
+ : ''
407
+ const handoffControls = editing ? (
408
+ <div className="space-y-2">
409
+ <div className="flex flex-wrap gap-2">
410
+ <button
411
+ onClick={handleCopyHandoff}
412
+ disabled={handoffCopying}
413
+ className="inline-flex items-center gap-2 rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-[12px] font-600 text-text-2 hover:bg-white/[0.08] disabled:opacity-50"
414
+ style={{ fontFamily: 'inherit' }}
415
+ >
416
+ <ClipboardCopy size={13} />
417
+ {handoffCopied ? 'Copied' : handoffCopying ? 'Copying...' : 'Copy Handoff'}
418
+ </button>
419
+ <a
420
+ href={handoffUrl}
421
+ target="_blank"
422
+ rel="noreferrer"
423
+ className="inline-flex items-center gap-2 rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-[12px] font-600 text-text-2 hover:bg-white/[0.08]"
424
+ >
425
+ <FileText size={13} />
426
+ Open Packet
427
+ </a>
428
+ <button
429
+ onClick={handleSaveHandoff}
430
+ disabled={handoffSaving}
431
+ className="inline-flex items-center gap-2 rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-[12px] font-600 text-text-2 hover:bg-white/[0.08] disabled:opacity-50"
432
+ style={{ fontFamily: 'inherit' }}
433
+ >
434
+ <Save size={13} />
435
+ {handoffSaving ? 'Saving...' : 'Save Packet'}
436
+ </button>
437
+ </div>
438
+ {handoffSavedPath && (
439
+ <code className="block text-[11px] text-text-3 font-mono break-all">{handoffSavedPath}</code>
440
+ )}
441
+ {handoffError && (
442
+ <p className="text-[12px] font-600 text-red-400">{handoffError}</p>
443
+ )}
444
+ </div>
445
+ ) : null
356
446
 
357
447
  /* ───── View-only mode ───── */
358
448
  if (viewOnly && editing) {
@@ -503,6 +593,7 @@ export function TaskSheet() {
503
593
  ))}
504
594
  </div>
505
595
  )}
596
+ {handoffControls}
506
597
  {!editing.executionWorkspace && (
507
598
  <button
508
599
  onClick={handlePrepareWorkspace}
@@ -1008,6 +1099,7 @@ export function TaskSheet() {
1008
1099
  ))}
1009
1100
  </div>
1010
1101
  )}
1102
+ {handoffControls}
1011
1103
  <button
1012
1104
  onClick={handlePrepareWorkspace}
1013
1105
  disabled={workspacePreparing}
@@ -0,0 +1,86 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { buildTaskHandoffPacket, formatTaskHandoffMarkdown } from '@/lib/server/tasks/task-handoff'
5
+ import type { BoardTask } from '@/types'
6
+
7
+ function task(overrides: Partial<BoardTask> = {}): BoardTask {
8
+ return {
9
+ id: 'task-main',
10
+ title: 'Prepare release handoff',
11
+ description: 'Summarize the release state for the next operator.',
12
+ status: 'backlog',
13
+ agentId: 'agent-1',
14
+ createdAt: 100,
15
+ updatedAt: 200,
16
+ ...overrides,
17
+ } as BoardTask
18
+ }
19
+
20
+ describe('task handoff packets', () => {
21
+ it('marks unresolved blockers as blocked and recommends a concrete action', () => {
22
+ const tasks: Record<string, BoardTask> = {
23
+ 'task-main': task({
24
+ blockedBy: ['dep-1'],
25
+ executionWorkspace: {
26
+ path: '/tmp/workspace',
27
+ mode: 'task',
28
+ preparedAt: 150,
29
+ previewLinks: [],
30
+ runtimeServices: [],
31
+ },
32
+ }),
33
+ 'dep-1': task({
34
+ id: 'dep-1',
35
+ title: 'Finish prerequisite',
36
+ status: 'running',
37
+ agentId: 'agent-2',
38
+ }),
39
+ }
40
+
41
+ const packet = buildTaskHandoffPacket(tasks['task-main'], tasks, { now: 300, runBrief: null })
42
+
43
+ assert.equal(packet.readiness.status, 'blocked')
44
+ assert.deepEqual(packet.dependencies.blockedBy.map((ref) => ref.id), ['dep-1'])
45
+ assert.equal(packet.readiness.checks.find((item) => item.id === 'dependencies')?.status, 'blocked')
46
+ assert.ok(packet.readiness.recommendedActions.some((action) => action.includes('unresolved blockers')))
47
+ })
48
+
49
+ it('summarizes workspace, evidence, and quality gate state in markdown', () => {
50
+ const mainTask = task({
51
+ status: 'completed',
52
+ result: 'Release completed with tests and browser smoke.',
53
+ completionReportPath: '/tmp/workspace/report.md',
54
+ outputFiles: ['/tmp/workspace/report.md'],
55
+ artifacts: [{ filename: 'smoke.txt', type: 'file', url: '/uploads/smoke.txt' }],
56
+ verificationSummary: 'npm run test passed',
57
+ qualityGate: {
58
+ enabled: true,
59
+ minResultChars: 10,
60
+ minEvidenceItems: 2,
61
+ requireVerification: true,
62
+ requireArtifact: true,
63
+ requireReport: true,
64
+ },
65
+ executionWorkspace: {
66
+ path: '/tmp/workspace',
67
+ mode: 'task',
68
+ contextPath: '/tmp/workspace/context.json',
69
+ envPath: '/tmp/workspace/.env.swarmclaw',
70
+ preparedAt: 150,
71
+ previewLinks: [{ id: 'preview', label: 'Preview', kind: 'web', url: 'http://127.0.0.1:3456', addedAt: 160 }],
72
+ runtimeServices: [{ id: 'dev', name: 'Dev server', status: 'running', updatedAt: 160 }],
73
+ },
74
+ })
75
+
76
+ const packet = buildTaskHandoffPacket(mainTask, { 'task-main': mainTask }, { now: 300, runBrief: null })
77
+ const markdown = formatTaskHandoffMarkdown(packet)
78
+
79
+ assert.equal(packet.readiness.status, 'ready')
80
+ assert.match(markdown, /# Task Handoff: Prepare release handoff/)
81
+ assert.match(markdown, /Readiness: ready/)
82
+ assert.match(markdown, /Workspace: \/tmp\/workspace/)
83
+ assert.match(markdown, /Preview: Preview http:\/\/127\.0\.0\.1:3456/)
84
+ assert.match(markdown, /Task report: \/tmp\/workspace\/report\.md/)
85
+ })
86
+ })
@@ -0,0 +1,365 @@
1
+ import { buildRunBrief } from '@/lib/server/runs/run-brief'
2
+ import { getUnifiedRunById, listUnifiedRunEvents, listUnifiedRuns } from '@/lib/server/runs/unified-run-queries'
3
+ import { computeTaskLiveness } from '@/lib/server/tasks/task-execution-workspace'
4
+ import type {
5
+ BoardTask,
6
+ RunBrief,
7
+ TaskHandoffCheck,
8
+ TaskHandoffPacket,
9
+ TaskHandoffReadinessStatus,
10
+ TaskHandoffTaskRef,
11
+ } from '@/types'
12
+
13
+ const MAX_MARKDOWN_TEXT = 900
14
+
15
+ function compactText(value: unknown, maxChars = MAX_MARKDOWN_TEXT): string | null {
16
+ if (typeof value !== 'string') return null
17
+ const text = value.split(/\s+/).filter(Boolean).join(' ').trim()
18
+ if (!text) return null
19
+ return text.length > maxChars ? `${text.slice(0, maxChars - 3)}...` : text
20
+ }
21
+
22
+ function toIso(value: number | null | undefined): string {
23
+ return value && Number.isFinite(value) ? new Date(value).toISOString() : 'n/a'
24
+ }
25
+
26
+ function taskRef(id: string, tasks: Record<string, BoardTask>, now: number): TaskHandoffTaskRef {
27
+ const task = tasks[id]
28
+ if (!task) {
29
+ return {
30
+ id,
31
+ title: id,
32
+ status: 'backlog',
33
+ agentId: null,
34
+ completedAt: null,
35
+ liveness: {
36
+ state: 'blocked',
37
+ reason: 'Referenced task is missing.',
38
+ checkedAt: now,
39
+ },
40
+ }
41
+ }
42
+ return {
43
+ id: task.id,
44
+ title: task.title || task.id,
45
+ status: task.status,
46
+ agentId: task.agentId || null,
47
+ completedAt: task.completedAt ?? null,
48
+ liveness: computeTaskLiveness(task, tasks, { now }),
49
+ }
50
+ }
51
+
52
+ function resolveLatestRunBrief(task: BoardTask): RunBrief | null {
53
+ const candidateIds = [
54
+ task.checkpoint?.lastRunId,
55
+ task.protocolRunId,
56
+ ].filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
57
+
58
+ for (const runId of candidateIds) {
59
+ const run = getUnifiedRunById(runId)
60
+ if (run) return buildRunBrief(run, listUnifiedRunEvents(runId, 300))
61
+ }
62
+
63
+ if (!task.sessionId) return null
64
+ const latestOwnedRun = listUnifiedRuns({ sessionId: task.sessionId, limit: 50 })
65
+ .find((run) => run.ownerType === 'task' && run.ownerId === task.id)
66
+ if (!latestOwnedRun) return null
67
+ return buildRunBrief(latestOwnedRun, listUnifiedRunEvents(latestOwnedRun.id, 300))
68
+ }
69
+
70
+ function check(id: string, label: string, status: TaskHandoffCheck['status'], detail?: string | null, taskIds?: string[]): TaskHandoffCheck {
71
+ return { id, label, status, detail: detail || null, ...(taskIds?.length ? { taskIds } : {}) }
72
+ }
73
+
74
+ function qualityChecks(task: BoardTask, runBrief: RunBrief | null): TaskHandoffCheck[] {
75
+ const gate = task.qualityGate || null
76
+ if (!gate?.enabled) return [check('quality-gate', 'Quality gate', 'ok', 'No task-specific gate is enabled.')]
77
+
78
+ const checks: TaskHandoffCheck[] = []
79
+ const resultLength = (task.result || '').trim().length
80
+ const minResultChars = Math.max(0, Math.trunc(gate.minResultChars ?? 0))
81
+ if (minResultChars > 0) {
82
+ checks.push(check(
83
+ 'quality-result',
84
+ 'Result length',
85
+ resultLength >= minResultChars ? 'ok' : 'warning',
86
+ resultLength >= minResultChars
87
+ ? `Result has ${resultLength} characters.`
88
+ : `Result has ${resultLength} of ${minResultChars} required characters.`,
89
+ ))
90
+ }
91
+
92
+ const evidenceCount =
93
+ (Array.isArray(task.outputFiles) ? task.outputFiles.length : 0)
94
+ + (Array.isArray(task.artifacts) ? task.artifacts.length : 0)
95
+ + (runBrief?.evidence.length || 0)
96
+ const minEvidenceItems = Math.max(0, Math.trunc(gate.minEvidenceItems ?? 0))
97
+ if (minEvidenceItems > 0) {
98
+ checks.push(check(
99
+ 'quality-evidence',
100
+ 'Evidence',
101
+ evidenceCount >= minEvidenceItems ? 'ok' : 'warning',
102
+ evidenceCount >= minEvidenceItems
103
+ ? `${evidenceCount} evidence signal${evidenceCount === 1 ? '' : 's'} available.`
104
+ : `${evidenceCount} of ${minEvidenceItems} required evidence signals are available.`,
105
+ ))
106
+ }
107
+
108
+ if (gate.requireVerification) {
109
+ const hasVerification = Boolean(compactText(task.verificationSummary, 240) || runBrief?.warnings.length === 0 && runBrief?.status === 'completed')
110
+ checks.push(check(
111
+ 'quality-verification',
112
+ 'Verification',
113
+ hasVerification ? 'ok' : 'warning',
114
+ hasVerification ? 'Verification signal is present.' : 'Add verification notes before handoff.',
115
+ ))
116
+ }
117
+
118
+ if (gate.requireArtifact) {
119
+ const artifactCount = Array.isArray(task.artifacts) ? task.artifacts.length : 0
120
+ checks.push(check(
121
+ 'quality-artifact',
122
+ 'Artifact',
123
+ artifactCount > 0 ? 'ok' : 'warning',
124
+ artifactCount > 0 ? `${artifactCount} artifact${artifactCount === 1 ? '' : 's'} attached.` : 'Attach at least one artifact.',
125
+ ))
126
+ }
127
+
128
+ if (gate.requireReport) {
129
+ checks.push(check(
130
+ 'quality-report',
131
+ 'Task report',
132
+ task.completionReportPath ? 'ok' : 'warning',
133
+ task.completionReportPath ? 'Task report path is present.' : 'Generate or attach a task report.',
134
+ ))
135
+ }
136
+
137
+ return checks.length > 0 ? checks : [check('quality-gate', 'Quality gate', 'ok', 'Enabled gate has no active requirements.')]
138
+ }
139
+
140
+ function readinessStatus(checks: TaskHandoffCheck[]): TaskHandoffReadinessStatus {
141
+ if (checks.some((item) => item.status === 'blocked')) return 'blocked'
142
+ if (checks.some((item) => item.status === 'warning')) return 'needs_attention'
143
+ return 'ready'
144
+ }
145
+
146
+ function recommendedActions(checks: TaskHandoffCheck[]): string[] {
147
+ const actions: string[] = []
148
+ for (const item of checks) {
149
+ if (item.status === 'ok') continue
150
+ if (item.id === 'dependencies') actions.push('Complete or remove unresolved blockers before queueing the task.')
151
+ else if (item.id === 'workspace') actions.push('Prepare or refresh the task workspace before handing it to another operator.')
152
+ else if (item.id === 'liveness') actions.push('Inspect the current run state and clear stale or failed execution before resuming.')
153
+ else if (item.id.startsWith('quality-')) actions.push(item.detail || 'Resolve the task quality gate before handoff.')
154
+ else if (item.detail) actions.push(item.detail)
155
+ }
156
+ return Array.from(new Set(actions)).slice(0, 8)
157
+ }
158
+
159
+ export function buildTaskHandoffPacket(
160
+ task: BoardTask,
161
+ tasks: Record<string, BoardTask>,
162
+ options: { now?: number; runBrief?: RunBrief | null } = {},
163
+ ): TaskHandoffPacket {
164
+ const now = options.now ?? Date.now()
165
+ const liveness = computeTaskLiveness(task, tasks, { now })
166
+ const blockedBy = (task.blockedBy || []).map((id) => taskRef(id, tasks, now))
167
+ const blocks = (task.blocks || []).map((id) => taskRef(id, tasks, now))
168
+ const unresolvedBlockerIds = blockedBy
169
+ .filter((ref) => ref.status !== 'completed')
170
+ .map((ref) => ref.id)
171
+ const runBrief = options.runBrief === undefined ? resolveLatestRunBrief(task) : options.runBrief
172
+ const gateChecks = qualityChecks(task, runBrief)
173
+ const executionWorkspace = task.executionWorkspace || null
174
+ const previewLinks = task.previewLinks && task.previewLinks.length > 0
175
+ ? task.previewLinks
176
+ : executionWorkspace?.previewLinks || []
177
+ const runtimeServices = task.runtimeServices && task.runtimeServices.length > 0
178
+ ? task.runtimeServices
179
+ : executionWorkspace?.runtimeServices || []
180
+
181
+ const checks: TaskHandoffCheck[] = [
182
+ check(
183
+ 'owner',
184
+ 'Owner',
185
+ task.agentId ? 'ok' : 'warning',
186
+ task.agentId ? `Assigned to ${task.agentId}.` : 'Assign an agent before execution.',
187
+ ),
188
+ check(
189
+ 'dependencies',
190
+ 'Dependencies',
191
+ unresolvedBlockerIds.length > 0 ? 'blocked' : 'ok',
192
+ unresolvedBlockerIds.length > 0
193
+ ? `Waiting on ${unresolvedBlockerIds.length} blocker${unresolvedBlockerIds.length === 1 ? '' : 's'}.`
194
+ : 'No unresolved blockers.',
195
+ unresolvedBlockerIds,
196
+ ),
197
+ check(
198
+ 'workspace',
199
+ 'Execution workspace',
200
+ executionWorkspace ? 'ok' : 'warning',
201
+ executionWorkspace ? 'Workspace is prepared.' : 'No task workspace is prepared.',
202
+ ),
203
+ check(
204
+ 'liveness',
205
+ 'Liveness',
206
+ liveness.state === 'failed' || liveness.state === 'dead_lettered' || liveness.state === 'cancelled'
207
+ ? 'blocked'
208
+ : liveness.state === 'stale' || liveness.state === 'retrying'
209
+ ? 'warning'
210
+ : 'ok',
211
+ liveness.reason,
212
+ ),
213
+ ...gateChecks,
214
+ ]
215
+
216
+ const status = readinessStatus(checks)
217
+ return {
218
+ schemaVersion: 1,
219
+ taskId: task.id,
220
+ title: task.title || task.id,
221
+ description: task.description || null,
222
+ objective: task.objective || null,
223
+ status: task.status,
224
+ priority: task.priority,
225
+ generatedAt: now,
226
+ updatedAt: task.updatedAt,
227
+ owner: {
228
+ agentId: task.agentId || null,
229
+ projectId: task.projectId || null,
230
+ sessionId: task.sessionId || null,
231
+ createdByAgentId: task.createdByAgentId || null,
232
+ delegatedByAgentId: task.delegatedByAgentId || null,
233
+ },
234
+ liveness,
235
+ execution: {
236
+ workspacePath: executionWorkspace?.path || null,
237
+ sourceCwd: executionWorkspace?.sourceCwd || task.cwd || null,
238
+ mode: executionWorkspace?.mode || null,
239
+ contextPath: executionWorkspace?.contextPath || null,
240
+ envPath: executionWorkspace?.envPath || null,
241
+ previewLinks,
242
+ runtimeServices,
243
+ },
244
+ dependencies: {
245
+ blockedBy,
246
+ blocks,
247
+ },
248
+ qualityGate: {
249
+ enabled: Boolean(task.qualityGate?.enabled),
250
+ config: task.qualityGate || null,
251
+ checks: gateChecks,
252
+ },
253
+ outputs: {
254
+ result: task.result || null,
255
+ error: task.error || null,
256
+ outputFiles: Array.isArray(task.outputFiles) ? task.outputFiles : [],
257
+ artifacts: Array.isArray(task.artifacts) ? task.artifacts : [],
258
+ completionReportPath: task.completionReportPath || null,
259
+ verificationSummary: task.verificationSummary || null,
260
+ },
261
+ resume: {
262
+ cliProvider: task.cliProvider || null,
263
+ cliResumeId: task.cliResumeId || null,
264
+ claudeResumeId: task.claudeResumeId || null,
265
+ codexResumeId: task.codexResumeId || null,
266
+ opencodeResumeId: task.opencodeResumeId || null,
267
+ geminiResumeId: task.geminiResumeId || null,
268
+ },
269
+ run: runBrief
270
+ ? {
271
+ runId: runBrief.runId,
272
+ sessionId: runBrief.sessionId,
273
+ title: runBrief.title,
274
+ status: runBrief.status,
275
+ result: runBrief.result,
276
+ error: runBrief.error,
277
+ warnings: runBrief.warnings,
278
+ evidenceCount: runBrief.evidence.length,
279
+ }
280
+ : null,
281
+ readiness: {
282
+ status,
283
+ checks,
284
+ recommendedActions: status === 'ready'
285
+ ? ['Handoff packet is ready to share.']
286
+ : recommendedActions(checks),
287
+ },
288
+ }
289
+ }
290
+
291
+ function lineForRef(ref: TaskHandoffTaskRef): string {
292
+ const suffix = ref.liveness?.reason ? `, ${ref.liveness.reason}` : ''
293
+ return `- ${ref.title} (${ref.id}): ${ref.status}${suffix}`
294
+ }
295
+
296
+ function appendSection(lines: string[], title: string, body: string[] = []) {
297
+ lines.push('', `## ${title}`)
298
+ if (body.length === 0) lines.push('None.')
299
+ else lines.push(...body)
300
+ }
301
+
302
+ export function formatTaskHandoffMarkdown(packet: TaskHandoffPacket): string {
303
+ const lines = [
304
+ `# Task Handoff: ${packet.title}`,
305
+ '',
306
+ `Generated: ${toIso(packet.generatedAt)}`,
307
+ `Task ID: ${packet.taskId}`,
308
+ `Status: ${packet.status}`,
309
+ `Readiness: ${packet.readiness.status}`,
310
+ `Owner: ${packet.owner.agentId || 'unassigned'}`,
311
+ `Updated: ${toIso(packet.updatedAt)}`,
312
+ ]
313
+
314
+ const objective = compactText(packet.objective || packet.description, 1400)
315
+ if (objective) appendSection(lines, 'Objective', [objective])
316
+
317
+ appendSection(lines, 'Liveness', [
318
+ `- State: ${packet.liveness.state}`,
319
+ `- Reason: ${packet.liveness.reason}`,
320
+ packet.liveness.nextWakeAt ? `- Next wake: ${toIso(packet.liveness.nextWakeAt)}` : '',
321
+ ].filter(Boolean))
322
+
323
+ appendSection(lines, 'Workspace', [
324
+ packet.execution.workspacePath ? `- Workspace: ${packet.execution.workspacePath}` : '- Workspace: not prepared',
325
+ packet.execution.sourceCwd ? `- Source: ${packet.execution.sourceCwd}` : '',
326
+ packet.execution.contextPath ? `- Context: ${packet.execution.contextPath}` : '',
327
+ packet.execution.envPath ? `- Env: ${packet.execution.envPath}` : '',
328
+ ].filter(Boolean))
329
+
330
+ appendSection(lines, 'Runtime', [
331
+ ...packet.execution.previewLinks.map((link) => `- Preview: ${link.label || 'Preview'} ${link.url}`),
332
+ ...packet.execution.runtimeServices.map((service) => `- Service: ${service.name} (${service.status})${service.url ? ` ${service.url}` : ''}`),
333
+ ])
334
+
335
+ appendSection(lines, 'Dependencies', [
336
+ ...packet.dependencies.blockedBy.map(lineForRef),
337
+ ...packet.dependencies.blocks.map((ref) => `- Blocks ${ref.title} (${ref.id}): ${ref.status}`),
338
+ ])
339
+
340
+ appendSection(lines, 'Quality Checks', packet.readiness.checks.map((item) => {
341
+ const detail = item.detail ? `, ${item.detail}` : ''
342
+ return `- ${item.label}: ${item.status}${detail}`
343
+ }))
344
+
345
+ appendSection(lines, 'Evidence', [
346
+ packet.outputs.result ? `- Result: ${compactText(packet.outputs.result, 500)}` : '',
347
+ packet.outputs.error ? `- Error: ${compactText(packet.outputs.error, 500)}` : '',
348
+ ...packet.outputs.outputFiles.map((file) => `- Output file: ${file}`),
349
+ ...packet.outputs.artifacts.map((artifact) => `- Artifact: ${artifact.filename} ${artifact.url}`),
350
+ packet.outputs.completionReportPath ? `- Task report: ${packet.outputs.completionReportPath}` : '',
351
+ packet.run ? `- Latest run: ${packet.run.title} (${packet.run.status}, ${packet.run.evidenceCount} evidence items)` : '',
352
+ ].filter(Boolean))
353
+
354
+ appendSection(lines, 'Resume Handles', [
355
+ packet.resume.cliResumeId ? `- ${packet.resume.cliProvider || 'CLI'}: ${packet.resume.cliResumeId}` : '',
356
+ packet.resume.claudeResumeId ? `- Claude: ${packet.resume.claudeResumeId}` : '',
357
+ packet.resume.codexResumeId ? `- Codex: ${packet.resume.codexResumeId}` : '',
358
+ packet.resume.opencodeResumeId ? `- OpenCode: ${packet.resume.opencodeResumeId}` : '',
359
+ packet.resume.geminiResumeId ? `- Gemini: ${packet.resume.geminiResumeId}` : '',
360
+ ].filter(Boolean))
361
+
362
+ appendSection(lines, 'Recommended Actions', packet.readiness.recommendedActions.map((action) => `- ${action}`))
363
+
364
+ return `${lines.join('\n')}\n`
365
+ }
package/src/lib/tasks.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { api } from './app/api-client'
2
- import type { BoardTask, TaskComment, TaskPreviewLink, TaskRuntimeService } from '../types'
2
+ import type { BoardTask, TaskComment, TaskHandoffPacket, TaskPreviewLink, TaskRuntimeService } from '../types'
3
3
 
4
4
  export const fetchTasks = (includeArchived = false) =>
5
5
  api<Record<string, BoardTask>>('GET', `/tasks${includeArchived ? '?includeArchived=true' : ''}`)
@@ -29,6 +29,14 @@ export interface GitHubIssueImportResult {
29
29
  skipped: GitHubIssueImportItem[]
30
30
  }
31
31
 
32
+ export interface TaskHandoffSnapshotResult {
33
+ packet: TaskHandoffPacket
34
+ files: {
35
+ markdownPath: string
36
+ jsonPath: string
37
+ }
38
+ }
39
+
32
40
  export type TaskWriteInput = Partial<BoardTask> & {
33
41
  title?: string
34
42
  description?: string
@@ -49,6 +57,17 @@ export const createTask = (data: TaskWriteInput & {
49
57
  export const updateTask = (id: string, data: TaskWriteInput) =>
50
58
  api<BoardTask>('PUT', `/tasks/${id}`, data)
51
59
 
60
+ export const fetchTaskHandoff = (id: string) =>
61
+ api<TaskHandoffPacket>('GET', `/tasks/${encodeURIComponent(id)}/handoff`)
62
+
63
+ export const fetchTaskHandoffMarkdown = (id: string) =>
64
+ api<string>('GET', `/tasks/${encodeURIComponent(id)}/handoff?format=markdown`)
65
+
66
+ export const saveTaskHandoffSnapshot = (id: string, options: { prepareWorkspace?: boolean } = {}) =>
67
+ api<TaskHandoffSnapshotResult>('POST', `/tasks/${encodeURIComponent(id)}/handoff`, {
68
+ prepareWorkspace: options.prepareWorkspace ?? true,
69
+ }, { timeoutMs: 30_000 })
70
+
52
71
  export const deleteTask = (id: string) =>
53
72
  api<BoardTask>('DELETE', `/tasks/${id}`)
54
73
 
package/src/types/task.ts CHANGED
@@ -240,3 +240,94 @@ export interface BoardTask {
240
240
  /** Customizable workflow state (separate from `status` lifecycle). */
241
241
  workflowStateId?: string | null
242
242
  }
243
+
244
+ export type TaskHandoffCheckStatus = 'ok' | 'warning' | 'blocked'
245
+ export type TaskHandoffReadinessStatus = 'ready' | 'needs_attention' | 'blocked'
246
+
247
+ export interface TaskHandoffTaskRef {
248
+ id: string
249
+ title: string
250
+ status: BoardTaskStatus
251
+ agentId?: string | null
252
+ completedAt?: number | null
253
+ liveness?: TaskLivenessSnapshot | null
254
+ }
255
+
256
+ export interface TaskHandoffCheck {
257
+ id: string
258
+ label: string
259
+ status: TaskHandoffCheckStatus
260
+ detail?: string | null
261
+ taskIds?: string[]
262
+ }
263
+
264
+ export interface TaskHandoffRunSummary {
265
+ runId: string
266
+ sessionId: string
267
+ title: string
268
+ status: string
269
+ result: string | null
270
+ error: string | null
271
+ warnings: string[]
272
+ evidenceCount: number
273
+ }
274
+
275
+ export interface TaskHandoffPacket {
276
+ schemaVersion: 1
277
+ taskId: string
278
+ title: string
279
+ description?: string | null
280
+ objective?: string | null
281
+ status: BoardTaskStatus
282
+ priority?: BoardTask['priority']
283
+ generatedAt: number
284
+ updatedAt: number
285
+ owner: {
286
+ agentId: string | null
287
+ projectId?: string | null
288
+ sessionId?: string | null
289
+ createdByAgentId?: string | null
290
+ delegatedByAgentId?: string | null
291
+ }
292
+ liveness: TaskLivenessSnapshot
293
+ execution: {
294
+ workspacePath?: string | null
295
+ sourceCwd?: string | null
296
+ mode?: TaskExecutionWorkspaceMode | null
297
+ contextPath?: string | null
298
+ envPath?: string | null
299
+ previewLinks: TaskPreviewLink[]
300
+ runtimeServices: TaskRuntimeService[]
301
+ }
302
+ dependencies: {
303
+ blockedBy: TaskHandoffTaskRef[]
304
+ blocks: TaskHandoffTaskRef[]
305
+ }
306
+ qualityGate: {
307
+ enabled: boolean
308
+ config: TaskQualityGateConfig | null
309
+ checks: TaskHandoffCheck[]
310
+ }
311
+ outputs: {
312
+ result?: string | null
313
+ error?: string | null
314
+ outputFiles: string[]
315
+ artifacts: NonNullable<BoardTask['artifacts']>
316
+ completionReportPath?: string | null
317
+ verificationSummary?: string | null
318
+ }
319
+ resume: {
320
+ cliProvider?: string | null
321
+ cliResumeId?: string | null
322
+ claudeResumeId?: string | null
323
+ codexResumeId?: string | null
324
+ opencodeResumeId?: string | null
325
+ geminiResumeId?: string | null
326
+ }
327
+ run: TaskHandoffRunSummary | null
328
+ readiness: {
329
+ status: TaskHandoffReadinessStatus
330
+ checks: TaskHandoffCheck[]
331
+ recommendedActions: string[]
332
+ }
333
+ }