Kanban:补齐任务操作链路,明确能力边界 (#615)

* [verified] fix(kanban): harden WUI parity bridge

- Align board slug normalization with canonical underscore/lowercase/64-char rules
- Validate malformed Kanban action bodies before CLI shell-out
- Narrow task log no-log handling and expose phase-1 capabilities
- Extend client/server regression coverage for parity actions

* fix(kanban): guard archived task detail actions

---------

Co-authored-by: ekko <152005280+EKKOLearnAI@users.noreply.github.com>
This commit is contained in:
Zhicheng Han
2026-05-11 15:26:24 +02:00
committed by GitHub
parent 3a1893d401
commit 6ff1c18ee2
12 changed files with 1079 additions and 91 deletions
+264 -61
View File
@@ -19,8 +19,14 @@ function firstQueryValue(value: string | string[] | undefined): string | undefin
}
function requestBoard(ctx: Context): string | null {
const rawBoard = firstQueryValue(ctx.query.board as string | string[] | undefined)
if (rawBoard !== undefined && !rawBoard.trim()) {
ctx.status = 400
ctx.body = { error: 'invalid board slug' }
return null
}
try {
return kanbanCli.normalizeBoardSlug(firstQueryValue(ctx.query.board as string | string[] | undefined))
return kanbanCli.normalizeBoardSlug(rawBoard)
} catch {
ctx.status = 400
ctx.body = { error: 'invalid board slug' }
@@ -28,6 +34,90 @@ function requestBoard(ctx: Context): string | null {
}
}
function validSeverity(value?: string): value is 'warning' | 'error' | 'critical' {
return value === undefined || value === 'warning' || value === 'error' || value === 'critical'
}
const MAX_LOG_TAIL_BYTES = 1_000_000
const MAX_DISPATCH_TASKS = 100
const MAX_DISPATCH_FAILURE_LIMIT = 100
type PositiveIntegerResult = { value?: number; error?: string }
type StringResult = { value?: string; error?: string }
type BooleanResult = { value?: boolean; error?: string }
type BodyResult = { body: Record<string, unknown>; error?: string }
function optionalPositiveInteger(value: unknown, name: string, max: number): PositiveIntegerResult {
if (value === undefined || value === null || value === '') return {}
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
return { error: `${name} must be a positive integer` }
}
if (value > max) {
return { error: `${name} must be <= ${max}` }
}
return { value }
}
function optionalPositiveIntegerQuery(value: string | undefined, name: string, max: number): PositiveIntegerResult {
if (value === undefined || value === '') return {}
const numeric = Number(value)
if (!Number.isInteger(numeric) || numeric <= 0) {
return { error: `${name} must be a positive integer` }
}
if (numeric > max) {
return { error: `${name} must be <= ${max}` }
}
return { value: numeric }
}
function requestBody(ctx: Context): BodyResult {
const body = ctx.request.body
if (body === undefined || body === null) return { body: {} }
if (typeof body !== 'object' || Array.isArray(body)) {
return { body: {}, error: 'request body must be an object' }
}
return { body: body as Record<string, unknown> }
}
function optionalString(value: unknown, name: string): StringResult {
if (value === undefined || value === null) return {}
if (typeof value !== 'string') return { error: `${name} must be a string` }
return { value }
}
function requiredNonEmptyString(value: unknown, name: string): StringResult {
if (typeof value !== 'string' || !value.trim()) return { error: `${name} is required` }
return { value }
}
function requiredNonEmptyStringArray(value: unknown, name: string): { value?: string[]; error?: string } {
if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || !item.trim())) {
return { error: `${name} is required` }
}
return { value }
}
function optionalBoolean(value: unknown, name: string): BooleanResult {
if (value === undefined || value === null) return {}
if (typeof value !== 'boolean') return { error: `${name} must be boolean` }
return { value }
}
function optionalInteger(value: unknown, name: string): PositiveIntegerResult {
if (value === undefined || value === null || value === '') return {}
if (typeof value !== 'number' || !Number.isInteger(value)) {
return { error: `${name} must be an integer` }
}
return { value }
}
function rejectBadRequest(ctx: Context, error?: string): boolean {
if (!error) return false
ctx.status = 400
ctx.body = { error }
return true
}
export async function listBoards(ctx: Context) {
const includeArchived = firstQueryValue(ctx.query.includeArchived as string | string[] | undefined) === 'true'
try {
@@ -40,21 +130,25 @@ export async function listBoards(ctx: Context) {
}
export async function createBoard(ctx: Context) {
const { slug, name, description, icon, color, switchCurrent } = ctx.request.body as {
slug?: string
name?: string
description?: string
icon?: string
color?: string
switchCurrent?: boolean
}
if (!slug?.trim()) {
ctx.status = 400
ctx.body = { error: 'slug is required' }
return
}
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const slug = requiredNonEmptyString(body.slug, 'slug')
const name = optionalString(body.name, 'name')
const description = optionalString(body.description, 'description')
const icon = optionalString(body.icon, 'icon')
const color = optionalString(body.color, 'color')
const switchCurrent = optionalBoolean(body.switchCurrent, 'switchCurrent')
if (rejectBadRequest(ctx, slug.error || name.error || description.error || icon.error || color.error || switchCurrent.error)) return
try {
const board = await kanbanCli.createBoard({ slug, name, description, icon, color, switchCurrent })
const board = await kanbanCli.createBoard({
slug: slug.value!,
name: name.value,
description: description.value,
icon: icon.value,
color: color.value,
switchCurrent: switchCurrent.value,
})
ctx.body = { board }
} catch (err: any) {
ctx.status = err.message?.includes('Invalid kanban board slug') ? 400 : 500
@@ -115,8 +209,9 @@ export async function get(ctx: Context) {
return
}
// For completed tasks, find related session from the worker's profile DB
if (detail.task.status === 'done' && detail.runs.length > 0) {
// For terminal tasks, find related session from the worker's profile DB.
// Archived tasks can still carry the worker result/session users need to inspect.
if ((detail.task.status === 'done' || detail.task.status === 'archived') && detail.runs.length > 0) {
const profile = getLatestRunProfile(detail)
if (profile) {
try {
@@ -166,22 +261,19 @@ export async function get(ctx: Context) {
}
export async function create(ctx: Context) {
const { title, body, assignee, priority, tenant } = ctx.request.body as {
title?: string
body?: string
assignee?: string
priority?: number
tenant?: string
}
if (!title) {
ctx.status = 400
ctx.body = { error: 'title is required' }
return
}
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const payload = bodyResult.body
const title = requiredNonEmptyString(payload.title, 'title')
const body = optionalString(payload.body, 'body')
const assignee = optionalString(payload.assignee, 'assignee')
const priority = optionalInteger(payload.priority, 'priority')
const tenant = optionalString(payload.tenant, 'tenant')
if (rejectBadRequest(ctx, title.error || body.error || assignee.error || priority.error || tenant.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
const task = await kanbanCli.createTask(title, { board, body, assignee, priority, tenant })
const task = await kanbanCli.createTask(title.value!, { board, body: body.value, assignee: assignee.value, priority: priority.value, tenant: tenant.value })
ctx.body = { task }
} catch (err: any) {
ctx.status = 500
@@ -190,19 +282,16 @@ export async function create(ctx: Context) {
}
export async function complete(ctx: Context) {
const { task_ids, summary } = ctx.request.body as {
task_ids?: string[]
summary?: string
}
if (!task_ids?.length) {
ctx.status = 400
ctx.body = { error: 'task_ids is required' }
return
}
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const payload = bodyResult.body
const taskIds = requiredNonEmptyStringArray(payload.task_ids, 'task_ids')
const summary = optionalString(payload.summary, 'summary')
if (rejectBadRequest(ctx, taskIds.error || summary.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
await kanbanCli.completeTasks(task_ids, summary, { board })
await kanbanCli.completeTasks(taskIds.value!, summary.value, { board })
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = 500
@@ -211,16 +300,14 @@ export async function complete(ctx: Context) {
}
export async function block(ctx: Context) {
const { reason } = ctx.request.body as { reason?: string }
if (!reason) {
ctx.status = 400
ctx.body = { error: 'reason is required' }
return
}
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const reason = requiredNonEmptyString(bodyResult.body.reason, 'reason')
if (rejectBadRequest(ctx, reason.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
await kanbanCli.blockTask(ctx.params.id, reason, { board })
await kanbanCli.blockTask(ctx.params.id, reason.value!, { board })
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = 500
@@ -229,16 +316,14 @@ export async function block(ctx: Context) {
}
export async function unblock(ctx: Context) {
const { task_ids } = ctx.request.body as { task_ids?: string[] }
if (!task_ids?.length) {
ctx.status = 400
ctx.body = { error: 'task_ids is required' }
return
}
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const taskIds = requiredNonEmptyStringArray(bodyResult.body.task_ids, 'task_ids')
if (rejectBadRequest(ctx, taskIds.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
await kanbanCli.unblockTasks(task_ids, { board })
await kanbanCli.unblockTasks(taskIds.value!, { board })
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = 500
@@ -247,16 +332,14 @@ export async function unblock(ctx: Context) {
}
export async function assign(ctx: Context) {
const { profile } = ctx.request.body as { profile?: string }
if (!profile) {
ctx.status = 400
ctx.body = { error: 'profile is required' }
return
}
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const profile = requiredNonEmptyString(bodyResult.body.profile, 'profile')
if (rejectBadRequest(ctx, profile.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
await kanbanCli.assignTask(ctx.params.id, profile, { board })
await kanbanCli.assignTask(ctx.params.id, profile.value!, { board })
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = 500
@@ -264,6 +347,126 @@ export async function assign(ctx: Context) {
}
}
export async function addComment(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const bodyPayload = bodyResult.body
const body = requiredNonEmptyString(bodyPayload.body, 'body')
const author = optionalString(bodyPayload.author, 'author')
if (rejectBadRequest(ctx, body.error || author.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.addComment(ctx.params.id, body.value!, { board, author: author.value })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function taskLog(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
const tailRaw = firstQueryValue(ctx.query.tail as string | string[] | undefined)
const tail = optionalPositiveIntegerQuery(tailRaw, 'tail', MAX_LOG_TAIL_BYTES)
if (rejectBadRequest(ctx, tail.error)) return
try {
ctx.body = await kanbanCli.getTaskLog(ctx.params.id, { board, tail: tail.value })
} catch (err: any) {
ctx.status = err.message?.includes('not found') ? 404 : 500
ctx.body = { error: err.message }
}
}
export async function diagnostics(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
const task = firstQueryValue(ctx.query.task as string | string[] | undefined)
const severity = firstQueryValue(ctx.query.severity as string | string[] | undefined)
if (!validSeverity(severity)) {
ctx.status = 400
ctx.body = { error: 'severity must be warning, error, or critical' }
return
}
try {
const diagnostics = await kanbanCli.getDiagnostics({ board, task, severity })
ctx.body = { diagnostics }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function reclaim(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const reason = optionalString(body.reason, 'reason')
if (rejectBadRequest(ctx, reason.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.reclaimTask(ctx.params.id, { board, reason: reason.value })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function reassign(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const profile = requiredNonEmptyString(body.profile, 'profile')
const reclaim = optionalBoolean(body.reclaim, 'reclaim')
const reason = optionalString(body.reason, 'reason')
if (rejectBadRequest(ctx, profile.error || reclaim.error || reason.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.reassignTask(ctx.params.id, profile.value!, { board, reclaim: reclaim.value, reason: reason.value })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function specify(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const author = optionalString(body.author, 'author')
if (rejectBadRequest(ctx, author.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
const results = await kanbanCli.specifyTask(ctx.params.id, { board, author: author.value })
ctx.body = { results }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function dispatch(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const dryRun = optionalBoolean(body.dryRun, 'dryRun')
const max = optionalPositiveInteger(body.max, 'max', MAX_DISPATCH_TASKS)
const failureLimit = optionalPositiveInteger(body.failureLimit, 'failureLimit', MAX_DISPATCH_FAILURE_LIMIT)
if (rejectBadRequest(ctx, dryRun.error || max.error || failureLimit.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
const result = await kanbanCli.dispatch({ board, dryRun: dryRun.value, max: max.value, failureLimit: failureLimit.value })
ctx.body = { result }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function stats(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
@@ -9,6 +9,8 @@ kanbanRoutes.delete('/api/hermes/kanban/boards/:slug', ctrl.archiveBoard)
kanbanRoutes.get('/api/hermes/kanban/capabilities', ctrl.capabilities)
kanbanRoutes.get('/api/hermes/kanban/stats', ctrl.stats)
kanbanRoutes.get('/api/hermes/kanban/assignees', ctrl.assignees)
kanbanRoutes.get('/api/hermes/kanban/diagnostics', ctrl.diagnostics)
kanbanRoutes.post('/api/hermes/kanban/dispatch', ctrl.dispatch)
kanbanRoutes.get('/api/hermes/kanban/artifact', ctrl.readArtifact)
kanbanRoutes.get('/api/hermes/kanban/search-sessions', ctrl.searchSessions)
kanbanRoutes.get('/api/hermes/kanban', ctrl.list)
@@ -18,3 +20,8 @@ kanbanRoutes.post('/api/hermes/kanban/complete', ctrl.complete)
kanbanRoutes.post('/api/hermes/kanban/unblock', ctrl.unblock)
kanbanRoutes.post('/api/hermes/kanban/:id/block', ctrl.block)
kanbanRoutes.post('/api/hermes/kanban/:id/assign', ctrl.assign)
kanbanRoutes.post('/api/hermes/kanban/:id/comments', ctrl.addComment)
kanbanRoutes.get('/api/hermes/kanban/:id/log', ctrl.taskLog)
kanbanRoutes.post('/api/hermes/kanban/:id/reclaim', ctrl.reclaim)
kanbanRoutes.post('/api/hermes/kanban/:id/reassign', ctrl.reassign)
kanbanRoutes.post('/api/hermes/kanban/:id/specify', ctrl.specify)
@@ -5,7 +5,11 @@ import { logger } from '../logger'
const execFileAsync = promisify(execFile)
const execOpts = { windowsHide: true }
const BOARD_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/
const BOARD_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
const NO_WORKER_LOG_PATTERNS = [
/^\(no log for [^)]+?\s+—\s+task may not have spawned yet\)$/i,
/^no worker log(?: for [^\n]+)?$/i,
]
function resolveHermesBin(): string {
const envBin = process.env.HERMES_BIN?.trim()
@@ -16,8 +20,9 @@ function resolveHermesBin(): string {
const HERMES_BIN = resolveHermesBin()
export function normalizeBoardSlug(board?: string | null): string {
const trimmed = board?.trim()
if (!trimmed) return 'default'
if (board === undefined || board === null) return 'default'
const trimmed = board.trim().toLowerCase()
if (!trimmed) throw new Error('Invalid kanban board slug')
if (!BOARD_SLUG_RE.test(trimmed)) {
throw new Error('Invalid kanban board slug')
}
@@ -125,6 +130,25 @@ export interface KanbanCapabilities {
source: 'hermes-cli'
supports: Record<string, boolean>
missing: string[]
capabilities: KanbanCapabilityStatus[]
}
export interface KanbanTaskLog {
task_id: string
path: string | null
exists: boolean
size_bytes: number
content: string
truncated: boolean
}
export interface KanbanCapabilityStatus {
key: string
status: 'supported' | 'partial' | 'missing'
reason?: string
canonicalRoute?: string
canonicalCommand?: string
requiresBoard: boolean
}
export interface KanbanBoardOptions {
@@ -196,24 +220,186 @@ export async function archiveBoard(slugInput: string): Promise<void> {
}
export async function getCapabilities(): Promise<KanbanCapabilities> {
const supports = {
explicitBoard: true,
boardsList: true,
boardCreate: true,
boardArchive: true,
cliCurrentSwitch: true,
taskCrudLite: true,
commentsWrite: false,
taskLog: false,
dispatch: false,
events: false,
diagnostics: false,
bulk: false,
const capabilities: KanbanCapabilityStatus[] = [
{ key: 'explicitBoard', status: 'supported', canonicalCommand: '--board', requiresBoard: true },
{ key: 'boardsList', status: 'supported', canonicalRoute: '/boards', canonicalCommand: 'boards list', requiresBoard: false },
{ key: 'boardCreate', status: 'supported', canonicalRoute: '/boards', canonicalCommand: 'boards create', requiresBoard: false },
{ key: 'boardArchive', status: 'supported', canonicalRoute: '/boards/{slug}', canonicalCommand: 'boards rm', requiresBoard: false },
{ key: 'cliCurrentSwitch', status: 'partial', reason: 'Backend keeps explicit board context and does not expose a WUI route for mutating canonical CLI current board', canonicalRoute: '/boards/{slug}/switch', canonicalCommand: 'boards switch', requiresBoard: false },
{ key: 'taskCrudLite', status: 'supported', canonicalRoute: '/tasks', canonicalCommand: 'list/show/create/complete/block/unblock/assign', requiresBoard: true },
{ key: 'commentsWrite', status: 'supported', canonicalRoute: '/tasks/{task_id}/comments', canonicalCommand: 'comment', requiresBoard: true },
{ key: 'commentsRead', status: 'supported', reason: 'Comments are returned on task detail responses', canonicalRoute: '/tasks/{task_id}', canonicalCommand: 'show --json', requiresBoard: true },
{ key: 'taskLog', status: 'supported', canonicalRoute: '/tasks/{task_id}/log', canonicalCommand: 'log', requiresBoard: true },
{ key: 'diagnostics', status: 'supported', canonicalRoute: '/diagnostics', canonicalCommand: 'diagnostics', requiresBoard: true },
{ key: 'reclaim', status: 'supported', canonicalRoute: '/tasks/{task_id}/reclaim', canonicalCommand: 'reclaim', requiresBoard: true },
{ key: 'reassign', status: 'supported', canonicalRoute: '/tasks/{task_id}/reassign', canonicalCommand: 'reassign', requiresBoard: true },
{ key: 'specify', status: 'supported', canonicalRoute: '/tasks/{task_id}/specify', canonicalCommand: 'specify', requiresBoard: true },
{ key: 'dispatch', status: 'supported', canonicalRoute: '/dispatch', canonicalCommand: 'dispatch', requiresBoard: true },
{ key: 'links', status: 'missing', reason: 'Deferred from current WUI parity batch', canonicalRoute: '/links', canonicalCommand: 'link/unlink', requiresBoard: true },
{ key: 'bulk', status: 'missing', reason: 'Deferred from current WUI parity batch', canonicalRoute: '/tasks/bulk', canonicalCommand: 'bulk-equivalent', requiresBoard: true },
{ key: 'events', status: 'missing', reason: 'Streaming strategy not selected for WUI yet', canonicalRoute: '/events', canonicalCommand: 'watch', requiresBoard: true },
{ key: 'homeSubscriptions', status: 'missing', reason: 'Deferred from current WUI parity batch', canonicalRoute: '/home-channels and subscription routes', canonicalCommand: 'notify-*', requiresBoard: true },
]
const supports = Object.fromEntries(capabilities.map(capability => [capability.key, capability.status === 'supported'])) as Record<string, boolean>
const missing = capabilities
.filter(capability => capability.status !== 'supported')
.map(capability => capability.key)
return { source: 'hermes-cli', supports, missing, capabilities }
}
function parseJsonPayload(stdout: string): unknown[] {
const trimmed = stdout.trim()
if (!trimmed) return []
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) return parsed
return [parsed]
}
function isNoWorkerLogError(err: any): boolean {
const lines = [err?.stderr, err?.stdout, err?.message]
.filter(Boolean)
.flatMap(value => String(value).split(/\r?\n/).map(line => line.trim()).filter(Boolean))
return lines.some(line => NO_WORKER_LOG_PATTERNS.some(pattern => pattern.test(line)))
}
function pushOptional(args: string[], flag: string, value?: string | number | null): void {
if (value !== undefined && value !== null && String(value).trim() !== '') args.push(flag, String(value))
}
export async function addComment(taskId: string, body: string, opts?: KanbanBoardOptions & { author?: string }): Promise<{ ok: boolean; output: string }> {
const args = [...boardArgs(opts?.board), 'comment', taskId, body]
pushOptional(args, '--author', opts?.author)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
return { ok: true, output: stdout }
} catch (err: any) {
logger.error(err, 'Hermes CLI: kanban comment failed')
throw new Error(`Failed to comment on kanban task: ${err.message}`)
}
}
export async function getTaskLog(taskId: string, opts?: KanbanBoardOptions & { tail?: number }): Promise<KanbanTaskLog> {
const args = [...boardArgs(opts?.board), 'log', taskId]
pushOptional(args, '--tail', opts?.tail)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
const sizeBytes = Buffer.byteLength(stdout, 'utf8')
return {
task_id: taskId,
path: null,
exists: true,
size_bytes: sizeBytes,
content: stdout,
truncated: opts?.tail !== undefined && sizeBytes >= opts.tail,
}
} catch (err: any) {
const detail = await getTask(taskId, opts)
if (!detail) throw new Error('Kanban task not found')
if ((err.code === 1 || err.status === 1) && isNoWorkerLogError(err)) {
return {
task_id: taskId,
path: null,
exists: false,
size_bytes: 0,
content: '',
truncated: false,
}
}
logger.error(err, 'Hermes CLI: kanban log failed')
throw new Error(`Failed to read kanban task log: ${err.message}`)
}
}
export async function getDiagnostics(opts?: KanbanBoardOptions & { task?: string; severity?: string }): Promise<unknown[]> {
const args = [...boardArgs(opts?.board), 'diagnostics', '--json']
pushOptional(args, '--task', opts?.task)
pushOptional(args, '--severity', opts?.severity)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
return JSON.parse(stdout)
} catch (err: any) {
logger.error(err, 'Hermes CLI: kanban diagnostics failed')
throw new Error(`Failed to get kanban diagnostics: ${err.message}`)
}
}
export async function reclaimTask(taskId: string, opts?: KanbanBoardOptions & { reason?: string }): Promise<{ ok: boolean; output: string }> {
const args = [...boardArgs(opts?.board), 'reclaim', taskId]
pushOptional(args, '--reason', opts?.reason)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
return { ok: true, output: stdout }
} catch (err: any) {
logger.error(err, 'Hermes CLI: kanban reclaim failed')
throw new Error(`Failed to reclaim kanban task: ${err.message}`)
}
}
export async function reassignTask(taskId: string, profile: string, opts?: KanbanBoardOptions & { reclaim?: boolean; reason?: string }): Promise<{ ok: boolean; output: string }> {
const args = [...boardArgs(opts?.board), 'reassign', taskId, profile]
if (opts?.reclaim) args.push('--reclaim')
pushOptional(args, '--reason', opts?.reason)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
return { ok: true, output: stdout }
} catch (err: any) {
logger.error(err, 'Hermes CLI: kanban reassign failed')
throw new Error(`Failed to reassign kanban task: ${err.message}`)
}
}
export async function specifyTask(taskId: string, opts?: KanbanBoardOptions & { author?: string }): Promise<unknown[]> {
const args = [...boardArgs(opts?.board), 'specify', taskId, '--json']
pushOptional(args, '--author', opts?.author)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
return parseJsonPayload(stdout)
} catch (err: any) {
logger.error(err, 'Hermes CLI: kanban specify failed')
throw new Error(`Failed to specify kanban task: ${err.message}`)
}
}
export async function dispatch(opts?: KanbanBoardOptions & { dryRun?: boolean; max?: number; failureLimit?: number }): Promise<unknown> {
const args = [...boardArgs(opts?.board), 'dispatch', '--json']
if (opts?.dryRun) args.push('--dry-run')
pushOptional(args, '--max', opts?.max)
pushOptional(args, '--failure-limit', opts?.failureLimit)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
})
return JSON.parse(stdout)
} catch (err: any) {
logger.error(err, 'Hermes CLI: kanban dispatch failed')
throw new Error(`Failed to dispatch kanban tasks: ${err.message}`)
}
const missing = Object.entries(supports)
.filter(([, supported]) => !supported)
.map(([name]) => name)
return { source: 'hermes-cli', supports, missing }
}
export async function listTasks(opts?: {