@goodandready/dsh-kanban 0.2.33 → 0.2.35

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/lib/routes.js CHANGED
@@ -1,1207 +1,72 @@
1
- import crypto from 'node:crypto'
2
- // HTTP route handlers for the board.
3
- //
4
- // The client bundle runs in the browser without direct access to SQLite or host services;
5
- // it interacts with these handlers. Handlers accept parsed inputs and the store instance,
6
- // returning response objects to facilitate testing without full HTTP server setups.
7
- // Request/response wrapper integration lives in `lib/index.js`.
8
-
9
- import { COLUMN_ORDER, columnsOf, wipLimitField, columnNamesOf } from './config.js'
10
- import { parsePlan, planItems, planProgress, taskState, queuePosition } from './plan.js'
11
- import { columnStats, doneWithin, stale } from './metrics.js'
12
- import { isStale } from './time.js'
13
- import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from './filters.js'
14
- import { parseBody } from './markdown.js'
15
- import { computeNextRun } from './cron.js'
16
-
17
- /** Maximum request payload size to prevent denial of service. */
18
- export const MAX_BODY_BYTES = 256 * 1024
19
-
20
- export function isLoopbackAddress(addr) {
21
- if (!addr || typeof addr !== 'string') return false
22
- const clean = addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
23
- return (
24
- clean === '127.0.0.1' ||
25
- clean === '::1' ||
26
- clean === 'localhost' ||
27
- clean === 'localhost.' ||
28
- clean.startsWith('127.')
29
- )
30
- }
31
-
32
- export function isLanAddress(addr) {
33
- if (!addr || typeof addr !== 'string') return false
34
- const clean = addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
35
- if (isLoopbackAddress(clean)) return true
36
- if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true
37
- const m = clean.match(/^172\.(\d+)\./)
38
- if (m) {
39
- const octet = parseInt(m[1], 10)
40
- if (octet >= 16 && octet <= 31) return true
41
- }
42
- return false
43
- }
44
-
45
- export function isTrustedHost(host) {
46
- if (!host || typeof host !== 'string') return false
47
- let hostname = host.trim()
48
- if (hostname.startsWith('[')) {
49
- const end = hostname.indexOf(']')
50
- if (end > 0) hostname = hostname.slice(1, end)
51
- } else {
52
- hostname = hostname.split(':')[0]
53
- }
54
- hostname = hostname.toLowerCase()
55
- if (!hostname) return false
56
- if (hostname === 'localhost' || hostname === 'localhost.' || hostname.endsWith('.local')) return true
57
- if (isLoopbackAddress(hostname) || isLanAddress(hostname)) return true
58
- return false
59
- }
60
-
61
- export function normalizeIp(addr) {
62
- if (!addr || typeof addr !== 'string') return ''
63
- return addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
64
- }
65
-
66
- export function cookieNameForAuthority(authority) {
67
- if (!authority || typeof authority !== 'string') return ''
68
- const hash = crypto.createHash('sha256').update(authority.trim()).digest('base64url')
69
- return 'dsh-auth-' + hash
70
- }
71
-
72
- export function isLoopbackCookieName(name, host = '') {
73
- if (!name || typeof name !== 'string' || !name.startsWith('dsh-auth-')) return false
74
- const authorities = [
75
- host,
76
- '127.0.0.1:3000',
77
- '127.0.0.1:3080',
78
- '127.0.0.1:3082',
79
- '127.0.0.1',
80
- 'localhost:3000',
81
- 'localhost:3080',
82
- 'localhost:3082',
83
- 'localhost',
84
- '[::1]:3000',
85
- '[::1]',
86
- ]
87
- for (const auth of authorities) {
88
- if (auth && isLoopbackAddress(auth.split(':')[0])) {
89
- if (cookieNameForAuthority(auth) === name) return true
90
- }
91
- }
92
- return false
93
- }
94
-
95
- export function hasClientCredentials(req) {
96
- if (!req || !req.headers) return false
97
- if (req.headers.authorization) return true
98
- if (req.headers['x-dsh-lan-pin']) return true
99
- const cookie = req.headers.cookie
100
- if (!cookie || typeof cookie !== 'string') return false
101
- if (cookie.includes('dsh_token=') || cookie.includes('dsh_lan_pin=')) return true
102
-
103
- const parts = cookie.split(';')
104
- for (const part of parts) {
105
- const trimmed = part.trim()
106
- const eqIdx = trimmed.indexOf('=')
107
- if (eqIdx === -1) continue
108
- const name = trimmed.slice(0, eqIdx).trim()
109
- if (name.startsWith('dsh-auth-')) {
110
- if (!isLoopbackCookieName(name, req.headers.host)) {
111
- return true
112
- }
113
- }
114
- }
115
- return false
116
- }
117
-
118
- export function getClientIp(req) {
119
- const socketAddress =
120
- req?.socket?.remoteAddress ||
121
- req?.connection?.remoteAddress ||
122
- req?.info?.remoteAddress ||
123
- ''
124
- const socketIp = normalizeIp(socketAddress)
125
- const xff = req?.headers?.['x-forwarded-for']
126
- if (xff && typeof xff === 'string' && (isLoopbackAddress(socketIp) || !socketIp)) {
127
- const first = xff.split(',')[0].trim()
128
- if (first) {
129
- return normalizeIp(first)
130
- }
131
- }
132
- return socketIp
133
- }
134
-
135
- /**
136
- * Cross-site request, DNS rebinding, and authentication validation (#261, #369, #384).
137
- *
138
- * Requirements:
139
- * 1. If DSH connection service is available (options.connection), requestRejection(req)
140
- * is the authoritative gate for browser session authentication and trusted network access.
141
- * If rejection is returned, the numeric status (e.g. 401 or 403) is returned.
142
- * 2. Host header is required and must target a trusted host (loopback, LAN, .local).
143
- * 3. Cross-site requests ('sec-fetch-site: cross-site' or origin mismatch) are rejected with 403.
144
- * 4. Origin & Referer headers (if present) must match Host or a trusted host.
145
- * 5. Network boundary validation:
146
- * - Loopback callers (127.0.0.1, ::1) are allowed.
147
- * - Non-loopback / LAN callers require valid connection authentication (fail-closed, return 401).
148
- * - Unknown remote address (socket present but empty remoteAddress) fails closed with 403.
149
- *
150
- * @param {import('node:http').IncomingMessage} req
151
- * @param {{ connection?: { requestRejection?: (req: any) => number | undefined } }} [options]
152
- * @returns {number|undefined} Rejection HTTP status code (401, 403), or undefined if allowed.
153
- */
154
- export function getRejectionStatus(req, options = {}) {
155
- const connection = options?.connection
156
- if (connection && typeof connection.requestRejection === 'function') {
157
- try {
158
- const rejection = connection.requestRejection(req)
159
- if (rejection !== undefined) {
160
- if (typeof rejection === 'number' && Number.isInteger(rejection) && rejection >= 400 && rejection < 600) {
161
- return rejection
162
- }
163
- return rejection === 401 ? 401 : 403
164
- }
165
- } catch {
166
- // Fail closed if connection service throws
167
- return 403
168
- }
169
- }
170
-
171
- if (!req || !req.headers) return 403
172
-
173
- const host = req.headers.host
174
- if (!host || !isTrustedHost(host)) {
175
- return 403
176
- }
177
-
178
- const site = req.headers['sec-fetch-site']
179
-
180
- // 1. Explicit cross-site or same-site requests are rejected
181
- if (site === 'cross-site' || site === 'same-site') {
182
- return 403
183
- }
184
-
185
- // 2. Validate origin against trusted hosts and host header
186
- const origin = req.headers.origin
187
- if (origin !== undefined) {
188
- if (origin === 'null' || !origin) return 403
189
- try {
190
- const parsed = new URL(origin)
191
- if (!isTrustedHost(parsed.hostname)) return 403
192
- if (parsed.host && parsed.host !== host) return 403
193
- } catch {
194
- return 403
195
- }
196
- }
197
-
198
- // 3. Validate referer against trusted hosts and host header if present
199
- const referer = req.headers.referer
200
- if (referer !== undefined && referer) {
201
- try {
202
- const parsedRef = new URL(referer)
203
- if (!isTrustedHost(parsedRef.hostname)) return 403
204
- if (parsedRef.host && parsedRef.host !== host) return 403
205
- } catch {
206
- return 403
207
- }
208
- }
209
-
210
- // 4. Validate remote address and network boundary
211
- const hasSocket = !!(req.socket || req.connection)
212
- const remote = getClientIp(req)
213
-
214
- if (hasSocket) {
215
- if (!remote) {
216
- return 403
217
- }
218
- if (!isLoopbackAddress(remote) && !isLanAddress(remote)) {
219
- return 403
220
- }
221
- // Remote / LAN callers without connection authentication are rejected (#384)
222
- if (!isLoopbackAddress(remote)) {
223
- if (!connection) {
224
- return 401
225
- }
226
- // If request was forwarded through a reverse proxy (x-forwarded-for),
227
- // the proxy may have injected loopback credentials. Validate that the client
228
- // itself supplied authentic credentials (bearer token, PIN, or client session cookie).
229
- const xff = req.headers['x-forwarded-for']
230
- if (xff && !hasClientCredentials(req)) {
231
- return 401
232
- }
233
- }
234
- } else {
235
- // Synthetic mock request without socket
236
- const xff = req.headers['x-forwarded-for']
237
- if (xff) {
238
- const first = xff.split(',')[0].trim()
239
- if (first && !isLoopbackAddress(normalizeIp(first))) {
240
- if (!connection || !hasClientCredentials(req)) {
241
- return 401
242
- }
243
- }
244
- } else if (!connection) {
245
- const hostname = String(host).split(':')[0].toLowerCase()
246
- if (!isLoopbackAddress(hostname) && hostname !== 'localhost') {
247
- return 401
248
- }
249
- }
250
- }
251
-
252
- // 5. Browser request with declared site:
253
- // Must be same-origin or none
254
- if (site && site !== 'same-origin' && site !== 'none') {
255
- return 403
256
- }
257
-
258
- // 6. Request without declared site from non-loopback requires connection
259
- if (!site && remote && !isLoopbackAddress(remote) && !connection) {
260
- return 401
261
- }
262
-
263
- return undefined
264
- }
265
-
266
- export function isTrustedRequest(req, options = {}) {
267
- return getRejectionStatus(req, options) === undefined
268
- }
269
-
270
- export function rejectUntrustedRequest(req, res, options = {}) {
271
- const status = getRejectionStatus(req, options)
272
- if (status !== undefined) {
273
- try {
274
- res.writeHead(status, {
275
- 'Content-Type': 'application/json; charset=utf-8',
276
- 'Cache-Control': 'no-store',
277
- })
278
- res.end(
279
- JSON.stringify({
280
- error: status === 401 ? 'unauthorized' : 'cross-site',
281
- })
282
- )
283
- } catch {
284
- /* socket closed */
285
- }
286
- return true
287
- }
288
- return false
289
- }
290
-
291
- /** Parse `/dsh-kanban/task/<id>[/<action>]`; returns undefined if non-matching. */
292
- export function parseTaskPath(pathname) {
293
- const m = /^\/dsh-kanban\/task\/([^/]+)(?:\/([a-z-]+))?\/?$/.exec(pathname ?? '')
294
- if (m === null) return undefined
295
- return { id: decodeURIComponent(m[1]), action: m[2] }
296
- }
297
-
298
- /**
299
- * Enrich task with parsed plan, state, and filters.
300
- *
301
- * Plan is pre-parsed on server because client bundle cannot import `lib/plan.js`.
302
- * `liveOf` returns active agent state or undefined if session is inactive.
303
- */
304
- function withPlan(task, liveOf, config, now, sameSession) {
305
- const plan = parsePlan(task.plan)
306
- // Registry error implies agent unavailable; do not fail whole board render.
307
- let live
308
- try {
309
- if (typeof liveOf === 'function' && task.sessionId) live = liveOf(task.sessionId)
310
- } catch { live = undefined }
311
- const liveStatus = typeof live === 'object' && live !== null ? live.status : live
312
- const liveStep = typeof live === 'object' && live !== null
313
- ? (live.step || live.activity || live.stateDetail || '')
314
- : ''
315
-
316
- const costMetrics = task.costMetrics || null
317
- const costLimitExceeded = (config?.costMeterEnabled !== false && Number(config?.taskCostLimit) > 0 && costMetrics?.costUsd)
318
- ? costMetrics.costUsd >= Number(config.taskCostLimit)
319
- : Boolean(task.costLimitExceeded)
320
-
321
- return {
322
- ...task,
323
- plan: { items: planItems(plan), progress: planProgress(plan) },
324
- state: taskState({ task, live: liveStatus }),
325
- liveStep: liveStep || task.liveStep || '',
326
- costMetrics,
327
- costLimitExceeded,
328
- // Queue position calculated dynamically across active tasks in same session.
329
- queuePos: queuePosition(task, sameSession),
330
- // Label facets extracted on server for consistent client filtering.
331
- facets: facetsOfTask(task),
332
- // Stale calculation performed on host where configuration resides.
333
- stale: isStale({
334
- now,
335
- updatedAt: task.updatedAt,
336
- sessionId: task.sessionId,
337
- afterMinutes: config?.staleAfterMin,
338
- }),
339
- // Overdue calculated on host using server clock.
340
- overdue: typeof task.dueAt === 'number' && task.dueAt > 0 && task.dueAt < now,
341
- }
342
- }
343
-
344
- /** Order tasks: by column, grouped by repository, with active tasks on top. */
345
- function orderForBoard(tasks, columns) {
346
- const out = []
347
- for (const column of columns) {
348
- const inColumn = tasks.filter((t) => t.column === column)
349
- for (const group of groupByRepo(sortByLife(inColumn))) out.push(...group.tasks)
350
- }
351
- // Retain tasks in columns not visible on current board to prevent data loss.
352
- const seen = new Set(out.map((t) => t.id))
353
- for (const task of tasks) if (!seen.has(task.id)) out.push(task)
354
- return out
355
- }
356
-
357
- /**
358
- * Build board response payload: columns in canonical order, tasks, and limits.
359
- */
360
- export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot, costMeterAvailable = false }) {
361
- // Uniform timestamp across snapshot calculation.
362
- const now = Date.now()
363
- // Tasks are sorted and grouped on server: repo grouping with active items first.
364
- const raw = store.listTasks({ board, repo })
365
- // Queue position scoped per session.
366
- const bySession = new Map()
367
- for (const task of raw) {
368
- const id = typeof task.sessionId === 'string' ? task.sessionId : ''
369
- if (id === '') continue
370
- if (!bySession.has(id)) bySession.set(id, [])
371
- bySession.get(id).push(task)
372
- }
373
- const tasks = orderForBoard(
374
- raw.map((task) => {
375
- const enriched = withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])
376
- if (task.isEpic) {
377
- enriched.subtasksCount = store.countSubtasks(task.id)
378
- }
379
- return enriched
380
- }),
381
- columnsOf(store.boardKind(board)),
382
- )
383
- const kind = store.boardKind(board)
384
- // Column custom names sent from server; locale translation handled by client.
385
- const names = columnNamesOf(config)
386
- const columns = columnsOf(kind).map((id) => {
387
- const count = tasks.filter((t) => t.column === id).length
388
- const limit = wipLimitField(config, id)
389
- return { id, name: names[id], count, limit, overLimit: limit !== undefined && count > limit }
390
- })
391
- // Filter counts calculated over all tasks on board.
392
- return {
393
- board, kind, now, boards: store.listBoards(), columns, tasks,
394
- // Resolved project root {path, set} computed on host.
395
- projectRoot: projectRoot ?? { path: '', set: false },
396
-
397
- facets: facetsOf(tasks),
398
- // Sync telemetry state attached directly to board snapshot.
399
- sync: sync ?? { state: 'never' },
400
- costMeterAvailable: Boolean(costMeterAvailable),
401
- }
402
- }
403
-
404
- /**
405
- * Card move transition. WIP limit is advisory: move executes, and exceeded status
406
- * is returned for UI highlighting.
407
- */
408
- export function applyMove({ store, config, id, column, beforeId, afterId, source = 'manual', detail = '' }) {
409
- const before = store.getTask(id)
410
- if (before === undefined) return { error: 'task-not-found', status: 404 }
411
- if (column !== undefined && column !== null) {
412
- const allowed = columnsOf(store.boardKind(before.board))
413
- if (!allowed.includes(column)) {
414
- return { error: 'invalid-column', status: 400 }
415
- }
416
- }
417
- const task = store.moveTask(id, { column, beforeId, afterId })
418
- if (task.column !== before.column) {
419
- store.addTransition({
420
- taskId: id, fromCol: before.column, toCol: task.column, source, detail,
421
- })
422
- }
423
- const limit = wipLimitField(config, task.column)
424
- const count = store.countInColumn(task.board, task.column)
425
- return { task, overLimit: limit !== undefined && count > limit }
426
- }
427
-
428
- /** Create local board task (not linked to Gitea). */
429
- export function createTask({ store, input }) {
430
- const title = typeof input?.title === 'string' ? input.title.trim() : ''
431
- if (title === '') return { error: 'title-required', status: 400 }
432
- const board = input?.board ?? 'main'
433
- // Validate column against board kind; simple boards do not have deploy/cleanup.
434
- const allowed = columnsOf(store.boardKind(board))
435
- const column = allowed.includes(input?.column) ? input.column : 'backlog'
436
- const task = store.createTask({
437
- board,
438
- column,
439
- title,
440
- body: typeof input?.body === 'string' ? input.body : '',
441
- labels: Array.isArray(input?.labels) ? input.labels.filter((l) => typeof l === 'string') : [],
442
- })
443
- return { task }
444
- }
445
-
446
- /** Update card fields. Column transitions are handled via applyMove. */
447
- export function updateTask({ store, id, input }) {
448
- const current = store.getTask(id)
449
- if (current === undefined) return { error: 'task-not-found', status: 404 }
450
- const patch = {}
451
- if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
452
- if (typeof input?.body === 'string') patch.body = input.body
453
- if (Array.isArray(input?.labels)) patch.labels = input.labels.filter((l) => typeof l === 'string')
454
- // Priority is local to board and not synced back to Gitea.
455
- if (PRIORITIES.includes(input?.priority)) patch.priority = input.priority
456
- // Deadline is local. 0 clears deadline; positive value sets it.
457
- if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
458
- patch.dueAt = Math.floor(input.dueAt)
459
- }
460
- if (typeof input?.cron === 'string') {
461
- patch.cron = input.cron.trim()
462
- patch.nextRunAt = patch.cron ? (computeNextRun(patch.cron) || 0) : 0
463
- }
464
- if (Array.isArray(input?.checklist)) patch.checklist = input.checklist
465
- if (input?.report && typeof input.report === 'object') patch.report = input.report
466
- if (Array.isArray(input?.comments)) patch.comments = input.comments
467
- if (typeof input?.rejectReason === 'string') patch.rejectReason = input.rejectReason
468
- if (typeof input?.progressDump === 'string') {
469
- patch.progressDump = input.progressDump
470
- }
471
- if (typeof input?.permission === 'string') {
472
- patch.permission = input.permission
473
- if (input.permission !== current.permission) {
474
- patch.permissionConfirmedAt = 0
475
- patch.permissionConfirmedTier = ''
476
- }
477
- }
478
-
479
- if (typeof input?.reasoningEffort === 'string') {
480
- const val = input.reasoningEffort.trim().toLowerCase()
481
- if (['', 'low', 'medium', 'high', 'default', 'none', 'minimal', 'thorough'].includes(val)) {
482
- patch.reasoningEffort = val
483
- }
484
- }
485
- if (typeof input?.forge === 'string') {
486
- const val = input.forge.trim().toLowerCase()
487
- if (val === 'github' || val === 'gitea') patch.forge = val
488
- }
489
- return { task: store.updateTask(id, patch) }
490
- }
491
-
492
- export function confirmPermission({ store, id, tier }) {
493
- const task = store.getTask(id)
494
- if (task === undefined) return { error: 'task-not-found', status: 404 }
495
- const effectiveTier = typeof tier === 'string' && tier ? tier : (task.permission || 'default')
496
- return { task: store.updateTask(id, { permissionConfirmedAt: Date.now(), permissionConfirmedTier: effectiveTier }) }
497
- }
498
-
499
- /** Known priority values. Empty string indicates unprioritized. */
500
- const PRIORITIES = ['', 'high', 'medium', 'low']
501
-
502
- /**
503
- * Append note to task body.
504
- * Handled on server to prevent overwriting edits made elsewhere.
505
- */
506
- export function appendNote({ store, id, input }) {
507
- const task = store.getTask(id)
508
- if (task === undefined) return { error: 'task-not-found', status: 404 }
509
- const note = typeof input?.text === 'string' ? input.text.trim() : ''
510
- if (note === '') return { error: 'note-required', status: 400 }
511
- const body = typeof task.body === 'string' ? task.body : ''
512
- return { task: store.updateTask(id, { body: body === '' ? note : `${body}\n\n${note}` }) }
513
- }
514
-
515
- /**
516
- * Archive or unarchive card.
517
- * Unarchiving restores card to its original column and position.
518
- */
519
- export function setArchived({ store, id, archived, now = Date.now() }) {
520
- const task = store.getTask(id)
521
- if (task === undefined) return { error: 'task-not-found', status: 404 }
522
- return { task: store.setArchived(id, archived ? now : 0) }
523
- }
524
-
525
- /** Query archived tasks list. */
526
- export function listArchive({ store }) {
527
- return { tasks: store.listArchived() }
528
- }
529
-
530
- /**
531
- * Assign task or clear assignment.
532
- * Empty login unassigns the task.
533
- *
534
- * @returns {{task: object}|{error: string, status: number}}
535
- */
536
- export function assignTask({ store, id, login }) {
537
- const task = store.getTask(id)
538
- if (task === undefined) return { error: 'task-not-found', status: 404 }
539
- const who = typeof login === 'string' ? login.trim() : ''
540
- if (who === (task.assignee ?? '')) return { task }
541
- store.updateTask(id, { assignee: who })
542
- store.addTransition({
543
- taskId: id,
544
- fromCol: task.column,
545
- toCol: task.column,
546
- source: 'manual',
547
- detail: who === '' ? 'unassigned' : `assigned: ${who}`,
548
- })
549
- return { task: store.getTask(id) }
550
- }
551
-
552
- export function deleteTask({ store, id }) {
553
- if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
554
- store.deleteTask(id)
555
- return { ok: true }
556
- }
557
-
558
- /**
559
- * Parse task markdown body into structured block elements.
560
- * Done on server so client bundle does not need markdown parser.
561
- */
562
- /**
563
- * Board metrics: WIP distribution and stale tasks.
564
- *
565
- * @returns {{columns: object[], done: object, stale: object[], tasks: number}}
566
- */
567
- export function boardMetrics({ store, board = 'main', now = Date.now(), staleDays = 14 }) {
568
- const tasks = store.listTasks({ board })
569
- const byTask = new Map()
570
- for (const row of store.listAllTransitions()) {
571
- if (!byTask.has(row.taskId)) byTask.set(row.taskId, [])
572
- byTask.get(row.taskId).push(row)
573
- }
574
- const rows = tasks.map((task) => ({ task, moves: byTask.get(task.id) ?? [] }))
575
- const day = 24 * 60 * 60 * 1000
576
-
577
- return {
578
- tasks: tasks.length,
579
- columns: columnStats(rows, now),
580
- done: {
581
- week: doneWithin(rows, now - 7 * day, now),
582
- month: doneWithin(rows, now - 30 * day, now),
583
- },
584
- staleDays,
585
- stale: stale(tasks, now, staleDays * day).slice(0, 50),
586
- }
587
- }
588
-
589
- /**
590
- * Check whether a task can be resumed.
591
- * Eligible when task has recorded sessionId but no active agent is running.
592
- */
593
- export function canRevive(task, live) {
594
- const has = typeof task?.sessionId === 'string' && task.sessionId !== ''
595
- return has && (live === undefined || live === null)
596
- }
597
-
598
- export function taskBody({ store, id }) {
599
- const task = store.getTask(id)
600
- if (task === undefined) return { error: 'task-not-found', status: 404 }
601
- return { blocks: parseBody(task.body) }
602
- }
603
-
604
- export function taskLog({ store, id }) {
605
- if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
606
- return { transitions: store.listTransitions(id) }
607
- }
608
-
609
- /** Find task associated with session; absence is normal response. */
610
- export function taskBySession({ store, sessionId, agents }) {
611
- const task = store.findTaskBySession(sessionId)
612
- if (task === undefined) return { task: null }
613
- let live
614
- try { live = agents?.get?.(sessionId) } catch { live = undefined }
615
- // Board kind returned with task so UI displays matching column choices.
616
- return {
617
- task,
618
- columns: columnsOf(store.boardKind(task.board)),
619
- canRevive: canRevive(task, live),
620
- }
621
- }
622
-
623
- /**
624
- * Export full board snapshot for backup or migration.
625
- */
626
- export function exportBoard({ store }) {
627
- return store.exportAll()
628
- }
629
-
630
- /**
631
- * Restore board from backup snapshot (idempotent; skips existing IDs).
632
- *
633
- * @returns {{imported: number, skipped: number, error?: string}}
634
- */
635
- export function importBoard({ store, input }) {
636
- let backupMeta = undefined
637
- if (typeof store.backupDatabase === 'function') {
638
- try {
639
- backupMeta = store.backupDatabase()
640
- } catch { /* backup best effort */ }
641
- }
642
- if (input === null || typeof input !== 'object') return { error: 'bad-import', status: 400 }
643
- if (input.version !== 1) return { error: 'bad-import-version', status: 400 }
644
- if (!Array.isArray(input.tasks)) return { error: 'bad-import', status: 400 }
645
-
646
- let imported = 0
647
- let skipped = 0
648
-
649
- const doImport = () => {
650
- for (const board of Array.isArray(input.boards) ? input.boards : []) {
651
- if (typeof board?.id !== 'string' || board.id === '') continue
652
- store.createBoard({
653
- id: board.id,
654
- title: board.title,
655
- kind: board.kind,
656
- position: board.position,
657
- createdAt: board.createdAt,
658
- })
659
- }
660
-
661
- for (const task of input.tasks) {
662
- if (typeof task?.id !== 'string' || task.id === '') continue
663
- if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
664
- if (typeof store.restoreTask === 'function') {
665
- store.restoreTask(task)
666
- } else {
667
- store.createTask(task)
668
- }
669
- imported += 1
670
- }
671
-
672
- for (const trans of Array.isArray(input.transitions) ? input.transitions : []) {
673
- if (typeof trans?.taskId !== 'string' || trans.taskId === '') continue
674
- if (typeof store.restoreTransition === 'function') {
675
- store.restoreTransition(trans)
676
- } else if (typeof store.addTransition === 'function') {
677
- store.addTransition(trans)
678
- }
679
- }
680
- }
681
-
682
- if (typeof store.withTransaction === 'function') {
683
- store.withTransaction(doImport)
684
- } else {
685
- doImport()
686
- }
687
-
688
- return { imported, skipped, backup: backupMeta?.backup }
689
- }
690
-
691
1
  /**
692
- * Export tasks to Excel-compatible CSV with UTF-8 BOM (#223).
693
- *
694
- * @param {{store: object, board?: string}} options
695
- * @returns {string} CSV string with UTF-8 BOM prefix
696
- */
697
- export function exportTasksCsv({ store, board = 'main' }) {
698
- const tasks = store.listTasks({ board })
699
- const headers = [
700
- 'ID',
701
- 'Title',
702
- 'Column',
703
- 'Priority',
704
- 'Repository',
705
- 'Issue #',
706
- 'Assignee',
707
- 'Model',
708
- 'Provider',
709
- 'Reasoning Effort',
710
- 'Workspace',
711
- 'Branch',
712
- 'Created',
713
- 'Updated',
714
- ]
715
-
716
- function escapeCsv(val) {
717
- if (val === null || val === undefined) return ''
718
- let str = String(val)
719
- // OWASP CSV Injection mitigation (#340)
720
- const trimmed = str.replace(/^[\s\t\r\n]+/, '')
721
- if (/^[=+\-@\t\r]/.test(str) || (trimmed.length > 0 && /^[=+\-@]/.test(trimmed))) {
722
- str = `'${str}`
723
- }
724
- if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
725
- return `"${str.replace(/"/g, '""')}"`
726
- }
727
- return str
728
- }
729
-
730
- function formatDate(ts) {
731
- if (!ts || typeof ts !== 'number') return ''
732
- try {
733
- return new Date(ts).toISOString().replace('T', ' ').slice(0, 19)
734
- } catch {
735
- return ''
736
- }
737
- }
738
-
739
- const rows = [headers.join(',')]
740
- for (const t of tasks) {
741
- const row = [
742
- escapeCsv(t.id),
743
- escapeCsv(t.title),
744
- escapeCsv(t.column),
745
- escapeCsv(t.priority),
746
- escapeCsv(t.repo),
747
- escapeCsv(t.issueNumber ?? ''),
748
- escapeCsv(t.assignee),
749
- escapeCsv(t.model),
750
- escapeCsv(t.provider),
751
- escapeCsv(t.reasoningEffort ?? ''),
752
- escapeCsv(t.workspaceId),
753
- escapeCsv(t.branch),
754
- escapeCsv(formatDate(t.createdAt)),
755
- escapeCsv(formatDate(t.updatedAt)),
756
- ]
757
- rows.push(row.join(','))
758
- }
759
-
760
- // UTF-8 BOM prefix for Microsoft Excel
761
- return '\uFEFF' + rows.join('\r\n')
762
- }
763
-
764
-
765
- /**
766
- * Clean up task worktree.
767
- */
768
- export async function handleCleanupWorktree({
769
- store, task, workspaceRegistry, force, cleanupTaskWorktree,
770
- config, repoDir, cwdOf, env,
771
- }) {
772
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
773
- const out = await cleanupTaskWorktree({
774
- store,
775
- task,
776
- workspaceRegistry,
777
- force: force === true,
778
- config,
779
- repoDir,
780
- cwdOf,
781
- env,
782
- })
783
- const status = out.status || (out.error ? (out.error === 'unfenced-worktree-path' ? 403 : 500) : 200)
784
- return { status, body: out }
785
- }
786
-
787
- /**
788
- * Get working copy diff for task.
789
- */
790
- export async function handleTaskDiff({ task, getTaskDiff, getTaskDiffInspector }) {
791
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
792
- if (typeof getTaskDiffInspector === 'function') {
793
- const inspectorResult = await getTaskDiffInspector({ worktreePath: task.worktree })
794
- return { status: 200, body: inspectorResult }
795
- }
796
- const diffResult = await getTaskDiff({ worktreePath: task.worktree })
797
- return { status: 200, body: diffResult }
798
- }
799
-
800
- /**
801
- * Discard file changes in task worktree (#371).
802
- */
803
- export async function handleTaskDiscard({ task, file, discardTaskFile }) {
804
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
805
- if (!file || typeof file !== 'string') return { status: 400, body: { error: 'file-required' } }
806
- if (!task.worktree) return { status: 400, body: { error: 'no-worktree' } }
807
- const result = await discardTaskFile({ worktreePath: task.worktree, file })
808
- return { status: result.success ? 200 : 400, body: result }
809
- }
810
-
811
- /**
812
- * Get commit list for task.
813
- */
814
- export async function handleTaskCommits({ task, getTaskCommits }) {
815
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
816
- const commits = await getTaskCommits({ worktreePath: task.worktree })
817
- return { status: 200, body: { commits } }
818
- }
819
-
820
- /**
821
- * Merge task worktree branch and transition to done.
822
- */
823
- export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
824
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
825
- const mergeResult = await mergeTaskWorktree({
826
- task,
827
- targetBranch: typeof targetBranch === 'string' ? targetBranch.trim() : undefined,
828
- workspaceRegistry,
829
- })
830
- if (!mergeResult.success) {
831
- return {
832
- status: 400,
833
- body: { error: mergeResult.error, detail: mergeResult.detail, files: mergeResult.files },
834
- }
835
- }
836
- const updated = store.acceptTask(task.id)
837
- store.addTransition({
838
- taskId: task.id,
839
- fromCol: task.column,
840
- toCol: 'done',
841
- source: 'manual',
842
- detail: `accepted and merged branch ${task.branch} into ${mergeResult.targetBranch}`,
843
- })
844
- return { status: 200, body: { task: updated, merged: true, targetBranch: mergeResult.targetBranch } }
845
- }
846
-
847
- /**
848
- * Check task worktree status (branch, dirty files, workspace).
849
- */
850
- export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
851
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
852
- const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
853
- return {
854
- status: 200,
855
- body: {
856
- worktree: task.worktree || '',
857
- branch: task.branch || '',
858
- workspaceId: task.workspaceId || '',
859
- dirty: dirtyCheck.dirty,
860
- files: dirtyCheck.files,
861
- },
862
- }
863
- }
864
-
865
- /**
866
- * Update Definition of Done checklist for task.
867
- */
868
- export function handleTaskChecklist({ store, task, input }) {
869
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
870
- const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
871
- const updated = store.updateChecklist(task.id, list)
872
- return { status: 200, body: { task: updated } }
873
- }
874
-
875
- /**
876
- * Set completion report for task.
877
- */
878
- export function handleTaskReport({ store, task, input }) {
879
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
880
- const updated = store.setReport(task.id, input)
881
- return { status: 200, body: { task: updated } }
882
- }
883
-
884
- /**
885
- * Get comments for task.
886
- */
887
- export function handleGetTaskComments({ task }) {
888
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
889
- return { status: 200, body: { comments: task.comments || [] } }
890
- }
891
-
892
- /**
893
- * Add comment to task.
894
- */
895
- export function handleAddTaskComment({ store, task, input }) {
896
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
897
- const author = input?.author || 'user'
898
- const role = input?.role || 'user'
899
- const text = input?.text || ''
900
- const updated = store.addComment(task.id, { author, role, text })
901
- return { status: 200, body: { task: updated } }
902
- }
903
-
904
- /**
905
- * Accept task review (transition to done).
906
- */
907
- export function handleAcceptTask({ store, task }) {
908
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
909
- const updated = store.acceptTask(task.id)
910
- return { status: 200, body: { task: updated } }
911
- }
912
-
913
- /**
914
- * Reject task review (return to in_progress).
915
- */
916
- export function handleRejectTask({ store, task, input }) {
917
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
918
- const reason = String(input?.reason || '').trim()
919
- if (!reason) return { status: 400, body: { error: 'reason-required' } }
920
- const updated = store.rejectTask(task.id, reason)
921
- return { status: 200, body: { task: updated } }
922
- }
923
-
924
- /**
925
- * Save task progress dump snapshot.
926
- */
927
- export function handleProgressDump({ store, task, input, formatProgressDump }) {
928
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
929
- const formatted = typeof input === 'string' ? input : formatProgressDump(input)
930
- const updated = store.updateTask(task.id, { progressDump: formatted })
931
- return { status: 200, body: { task: updated } }
932
- }
933
-
934
- /**
935
- * Decompose a task into subtasks and make the parent an Epic.
936
- */
937
- export async function handleTriageTask({ store, gitea, task, input }) {
938
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
939
- const subtasksInput = Array.isArray(input?.subtasks) ? input.subtasks : []
940
- if (subtasksInput.length === 0) {
941
- return { status: 400, body: { error: 'subtasks-required' } }
942
- }
943
- const createGitea = input?.createGiteaIssues === true && Boolean(task.owner && task.repo && gitea)
944
-
945
- // Convert parent into Epic
946
- store.updateTask(task.id, { isEpic: true })
947
-
948
- const created = []
949
- for (const item of subtasksInput) {
950
- const title = String(item?.title ?? '').trim()
951
- if (!title) continue
952
- const body = String(item?.body ?? '').trim()
953
- let issueNumber = null
954
- let issueUrl = null
955
-
956
- if (createGitea) {
957
- try {
958
- const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
959
- const giteaIssue = await gitea.createIssue({
960
- owner: task.owner,
961
- repo: task.repo,
962
- title,
963
- body: issueBody,
964
- })
965
- if (giteaIssue?.number) {
966
- issueNumber = giteaIssue.number
967
- issueUrl = giteaIssue.html_url || null
968
- }
969
- } catch { /* ignore gitea subtask creation failure */ }
970
- }
971
-
972
- const sub = store.createTask({
973
- board: task.board || 'main',
974
- column: 'backlog',
975
- title,
976
- body,
977
- parentId: task.id,
978
- owner: task.owner || null,
979
- repo: task.repo || null,
980
- issueNumber,
981
- issueUrl,
982
- labels: Array.isArray(item?.labels) ? item.labels : [],
983
- workspaceId: task.workspaceId || '',
984
- })
985
- created.push(sub)
986
- }
987
-
988
- store.addTransition({
989
- taskId: task.id,
990
- fromCol: task.column,
991
- toCol: task.column,
992
- source: 'manual',
993
- detail: `decomposed into ${created.length} subtasks (epic triage)`,
994
- })
995
-
996
- return {
997
- status: 200,
998
- body: {
999
- epic: store.getTask(task.id),
1000
- subtasks: store.listSubtasks(task.id),
1001
- counts: store.countSubtasks(task.id),
1002
- createdCount: created.length,
1003
- },
1004
- }
1005
- }
1006
-
1007
- /**
1008
- * Get subtasks of a task.
1009
- */
1010
- export function handleGetSubtasks({ store, task }) {
1011
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
1012
- return {
1013
- status: 200,
1014
- body: {
1015
- subtasks: store.listSubtasks(task.id),
1016
- counts: store.countSubtasks(task.id),
1017
- },
1018
- }
1019
- }
1020
-
1021
- /**
1022
- * Create a single subtask linked to a parent task.
1023
- */
1024
- export async function handleCreateSubtask({ store, gitea, task, input }) {
1025
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
1026
- const title = String(input?.title ?? '').trim()
1027
- if (!title) return { status: 400, body: { error: 'title-required' } }
1028
- const body = String(input?.body ?? '').trim()
1029
- let issueNumber = null
1030
- let issueUrl = null
1031
-
1032
- if (input?.createGiteaIssue === true && task.owner && task.repo && gitea) {
1033
- try {
1034
- const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
1035
- const giteaIssue = await gitea.createIssue({
1036
- owner: task.owner,
1037
- repo: task.repo,
1038
- title,
1039
- body: issueBody,
1040
- })
1041
- if (giteaIssue?.number) {
1042
- issueNumber = giteaIssue.number
1043
- issueUrl = giteaIssue.html_url || null
1044
- }
1045
- } catch { /* ignore gitea subtask creation failure */ }
1046
- }
1047
-
1048
- store.updateTask(task.id, { isEpic: true })
1049
- const sub = store.createTask({
1050
- board: task.board || 'main',
1051
- column: input?.column || 'backlog',
1052
- title,
1053
- body,
1054
- parentId: task.id,
1055
- owner: task.owner || null,
1056
- repo: task.repo || null,
1057
- issueNumber,
1058
- issueUrl,
1059
- labels: Array.isArray(input?.labels) ? input.labels : [],
1060
- workspaceId: task.workspaceId || '',
1061
- })
1062
- return { status: 200, body: { subtask: sub, counts: store.countSubtasks(task.id) } }
1063
- }
1064
-
1065
- /**
1066
- * Toggle a checklist item atomically by index or text.
1067
- */
1068
- export function handleToggleChecklistItem({ store, task, input }) {
1069
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
1070
- const current = Array.isArray(task.checklist) ? [...task.checklist] : []
1071
- let targetIndex = -1
1072
-
1073
- if (typeof input?.index === 'number' && input.index >= 0 && input.index < current.length) {
1074
- targetIndex = input.index
1075
- } else if (typeof input?.text === 'string' && input.text.trim()) {
1076
- const q = input.text.trim().toLowerCase()
1077
- targetIndex = current.findIndex((item) => {
1078
- const t = typeof item === 'string' ? item : item?.text
1079
- return typeof t === 'string' && t.toLowerCase().includes(q)
1080
- })
1081
- }
1082
-
1083
- if (targetIndex === -1) {
1084
- return { status: 404, body: { error: 'checklist-item-not-found' } }
1085
- }
1086
-
1087
- const item = current[targetIndex]
1088
- let updatedItem
1089
- if (typeof item === 'string') {
1090
- const checked = input?.checked !== undefined ? Boolean(input.checked) : true
1091
- updatedItem = { text: item, completed: checked, done: checked }
1092
- } else {
1093
- const isCurrentlyDone = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
1094
- const checked = input?.checked !== undefined ? Boolean(input.checked) : !isCurrentlyDone
1095
- updatedItem = { ...item, completed: checked, done: checked }
1096
- }
1097
- current[targetIndex] = updatedItem
1098
- const updated = store.updateChecklist(task.id, current)
1099
- return { status: 200, body: { task: updated, item: updatedItem, index: targetIndex } }
1100
- }
1101
-
1102
- /**
1103
- * Create a Gitea Pull Request for a task.
1104
- */
1105
- export async function handleCreateTaskPr({ store, gitea, task, input }) {
1106
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
1107
- if (!task.owner || !task.repo) {
1108
- return { status: 400, body: { error: 'task-no-repo' } }
1109
- }
1110
- const head = String(input?.head || task.branch || '').trim()
1111
- if (!head) return { status: 400, body: { error: 'branch-required' } }
1112
- const base = String(input?.base || 'main').trim()
1113
- const title = String(input?.title || task.title || `PR: ${task.title}`).trim()
1114
- const body = String(input?.body || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
1115
-
1116
- try {
1117
- const pr = await gitea.createPullRequest({
1118
- owner: task.owner,
1119
- repo: task.repo,
1120
- title,
1121
- body,
1122
- head,
1123
- base,
1124
- })
1125
- store.addTransition({
1126
- taskId: task.id,
1127
- fromCol: task.column,
1128
- toCol: task.column,
1129
- source: 'manual',
1130
- detail: `Created PR #${pr?.number || ''}: ${head} -> ${base}`,
1131
- })
1132
- return { status: 200, body: { pr, prUrl: pr?.html_url } }
1133
- } catch (err) {
1134
- return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
1135
- }
1136
- }
1137
- /**
1138
- * Handle interactive live steering (nudge) for an active task agent (#373).
1139
- *
1140
- * @param {{
1141
- * store: object,
1142
- * id: string,
1143
- * prompt: string,
1144
- * agents?: object,
1145
- * createMessage?: Function,
1146
- * eventHub?: object,
1147
- * now?: number
1148
- * }} options
1149
- * @returns {Promise<{ status: number, body: object }>}
1150
- */
1151
- export async function handleTaskNudge({ store, id, prompt, agents, createMessage, eventHub, now = Date.now() }) {
1152
- if (!store || !id) return { status: 400, body: { error: 'invalid-parameters' } }
1153
- const task = store.getTask(id)
1154
- if (!task) return { status: 404, body: { error: 'task-not-found' } }
1155
-
1156
- const text = typeof prompt === 'string' ? prompt.trim() : ''
1157
- if (!text) return { status: 400, body: { error: 'prompt-required' } }
1158
-
1159
- const sessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
1160
- if (!sessionId) return { status: 409, body: { error: 'no-session' } }
1161
-
1162
- let agent
1163
- try { agent = agents?.get?.(sessionId) } catch { agent = undefined }
1164
- if (!agent || typeof agent.followup !== 'function') {
1165
- return { status: 409, body: { error: 'session-not-live' } }
1166
- }
1167
-
1168
- const msg = typeof createMessage === 'function'
1169
- ? createMessage({
1170
- content: [{ type: 'text', text }],
1171
- source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: 'task-nudge' },
1172
- })
1173
- : { content: [{ type: 'text', text }] }
1174
-
1175
- agent.followup(msg)
1176
-
1177
- // Append user steering comment to task
1178
- if (typeof store.addComment === 'function') {
1179
- store.addComment(id, {
1180
- author: 'user',
1181
- role: 'user',
1182
- text: `[Steering Nudge] ${text}`,
1183
- })
1184
- } else if (typeof store.addTaskComment === 'function') {
1185
- store.addTaskComment(id, {
1186
- author: 'user',
1187
- role: 'user',
1188
- text: `[Steering Nudge] ${text}`,
1189
- })
1190
- }
1191
-
1192
- // Broadcast change via eventHub
1193
- if (eventHub && typeof eventHub.broadcast === 'function') {
1194
- eventHub.broadcast('task:change', { id, col: task.col || task.column })
1195
- }
1196
-
1197
- return {
1198
- status: 200,
1199
- body: {
1200
- success: true,
1201
- id,
1202
- sessionId,
1203
- prompt: text,
1204
- nudgedAt: now,
1205
- },
1206
- }
1207
- }
2
+ * Unified routes facade (#389).
3
+ * Decomposed into domain modules under lib/routes/ (security, board, task, task-worktree, task-actions).
4
+ * Re-exports all functions to maintain 100% backward compatibility with existing tests and imports.
5
+ * Zero Cyrillic in lib/ according to Issue #366.
6
+ */
7
+
8
+ export {
9
+ MAX_BODY_BYTES,
10
+ isLoopbackAddress,
11
+ isLanAddress,
12
+ isTrustedHost,
13
+ normalizeIp,
14
+ cookieNameForAuthority,
15
+ isLoopbackCookieName,
16
+ hasClientCredentials,
17
+ getClientIp,
18
+ getRejectionStatus,
19
+ isTrustedRequest,
20
+ rejectUntrustedRequest,
21
+ } from './routes/security.js'
22
+
23
+ export {
24
+ withPlan,
25
+ buildBoard,
26
+ boardMetrics,
27
+ exportBoard,
28
+ importBoard,
29
+ exportTasksCsv,
30
+ } from './routes/board.js'
31
+
32
+ export {
33
+ parseTaskPath,
34
+ applyMove,
35
+ createTask,
36
+ updateTask,
37
+ confirmPermission,
38
+ appendNote,
39
+ setArchived,
40
+ listArchive,
41
+ assignTask,
42
+ deleteTask,
43
+ canRevive,
44
+ taskBody,
45
+ taskLog,
46
+ taskBySession,
47
+ } from './routes/task.js'
48
+
49
+ export {
50
+ handleCleanupWorktree,
51
+ handleTaskDiff,
52
+ handleTaskDiscard,
53
+ handleTaskCommits,
54
+ handleTaskMerge,
55
+ handleTaskWorktreeStatus,
56
+ } from './routes/task-worktree.js'
57
+
58
+ export {
59
+ handleTaskChecklist,
60
+ handleTaskReport,
61
+ handleGetTaskComments,
62
+ handleAddTaskComment,
63
+ handleAcceptTask,
64
+ handleRejectTask,
65
+ handleProgressDump,
66
+ handleTriageTask,
67
+ handleGetSubtasks,
68
+ handleCreateSubtask,
69
+ handleToggleChecklistItem,
70
+ handleCreateTaskPr,
71
+ handleTaskNudge,
72
+ } from './routes/task-actions.js'