Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions packages/client/src/api/hermes/kanban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,29 @@ export interface KanbanBoardCreateRequest {
switchCurrent?: boolean
}

export interface KanbanCapabilityStatus {
key: string
status: 'supported' | 'partial' | 'missing'
reason?: string
canonicalRoute?: string
canonicalCommand?: string
requiresBoard: boolean
}

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 KanbanCreateRequest {
Expand All @@ -146,6 +165,39 @@ export interface KanbanListOptions extends KanbanBoardOptions {
includeArchived?: boolean
}

export interface KanbanCommentCreateRequest {
body: string
author?: string
}

export interface KanbanTaskLogOptions extends KanbanBoardOptions {
tail?: number
}

export interface KanbanDiagnosticsOptions extends KanbanBoardOptions {
task?: string
severity?: 'warning' | 'error' | 'critical'
}

export interface KanbanReclaimOptions extends KanbanBoardOptions {
reason?: string
}

export interface KanbanReassignOptions extends KanbanBoardOptions {
reclaim?: boolean
reason?: string
}

export interface KanbanSpecifyOptions extends KanbanBoardOptions {
author?: string
}

export interface KanbanDispatchOptions extends KanbanBoardOptions {
dryRun?: boolean
max?: number
failureLimit?: number
}

function normalizedBoard(board?: string): string {
const trimmed = board?.trim()
return trimmed || 'default'
Expand Down Expand Up @@ -240,6 +292,58 @@ export async function assignTask(taskId: string, profile: string, opts?: KanbanB
})
}

export async function addComment(taskId: string, data: KanbanCommentCreateRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/comments`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify(data),
})
}

export async function getTaskLog(taskId: string, opts?: KanbanTaskLogOptions): Promise<KanbanTaskLog> {
const params = boardParams(opts?.board)
if (opts?.tail !== undefined) params.set('tail', String(opts.tail))
return request<KanbanTaskLog>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/log`, params))
}

export async function getDiagnostics(opts?: KanbanDiagnosticsOptions): Promise<unknown[]> {
const params = boardParams(opts?.board)
if (opts?.task) params.set('task', opts.task)
if (opts?.severity) params.set('severity', opts.severity)
const res = await request<{ diagnostics: unknown[] }>(appendQuery('/api/hermes/kanban/diagnostics', params))
return res.diagnostics
}

export async function reclaimTask(taskId: string, opts?: KanbanReclaimOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/reclaim`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ reason: opts?.reason }),
})
}

export async function reassignTask(taskId: string, profile: string, opts?: KanbanReassignOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/reassign`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ profile, reclaim: opts?.reclaim, reason: opts?.reason }),
})
}

export async function specifyTask(taskId: string, opts?: KanbanSpecifyOptions): Promise<unknown[]> {
const res = await request<{ results: unknown[] }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/specify`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ author: opts?.author }),
})
return res.results
}

export async function dispatch(opts?: KanbanDispatchOptions): Promise<unknown> {
const params = boardParams(opts?.board)
const res = await request<{ result: unknown }>(appendQuery('/api/hermes/kanban/dispatch', params), {
method: 'POST',
body: JSON.stringify({ dryRun: opts?.dryRun, max: opts?.max, failureLimit: opts?.failureLimit }),
})
return res.result
}

export async function getStats(opts?: KanbanBoardOptions): Promise<KanbanStats> {
const res = await request<{ stats: KanbanStats }>(appendQuery('/api/hermes/kanban/stats', boardParams(opts?.board)))
return res.stats
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ const localizedTaskStatus = computed(() => {
return t(`kanban.columns.${detail.value.task.status}`, detail.value.task.status)
})

const canMutateTask = computed(() => {
const status = detail.value?.task.status
return status !== 'done' && status !== 'archived'
})

const sessionResults = ref<any[]>([])
const sessionLoading = ref(false)
const showSessions = ref(false)
Expand Down Expand Up @@ -243,8 +248,8 @@ async function handleAssign() {
<div class="result-summary" @click="openResultDetail">{{ completionSummary }}</div>
</div>

<!-- Actions (only for non-completed tasks) -->
<div v-if="detail.task.status !== 'done'" class="detail-section">
<!-- Actions (only for active, mutable tasks) -->
<div v-if="canMutateTask" class="detail-section">
<div class="section-title">{{ t('kanban.action.title') }}</div>
<div class="action-group">
<template v-if="!showCompleteInput">
Expand Down
79 changes: 76 additions & 3 deletions packages/client/src/stores/hermes/kanban.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import * as kanbanApi from '@/api/hermes/kanban'
import type { KanbanTask, KanbanStats, KanbanAssignee, KanbanBoard, KanbanCapabilities } from '@/api/hermes/kanban'
import type { KanbanTask, KanbanStats, KanbanAssignee, KanbanBoard, KanbanCapabilities, KanbanDiagnosticsOptions, KanbanDispatchOptions } from '@/api/hermes/kanban'

export const KANBAN_SELECTED_BOARD_STORAGE_KEY = 'hermes.kanban.selectedBoard'
export const DEFAULT_KANBAN_BOARD = 'default'

const BOARD_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/
const BOARD_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/

function safeStorageGet(key: string): string | null {
if (typeof window === 'undefined') return null
Expand All @@ -27,7 +27,7 @@ function safeStorageSet(key: string, value: string) {
}

export function normalizeBoardSlug(board?: string | null): string {
const trimmed = board?.trim()
const trimmed = board?.trim().toLowerCase()
if (!trimmed) return DEFAULT_KANBAN_BOARD
return BOARD_SLUG_RE.test(trimmed) ? trimmed : DEFAULT_KANBAN_BOARD
}
Expand Down Expand Up @@ -72,6 +72,20 @@ export const useKanbanStore = defineStore('kanban', () => {
return visible
})

function isCapabilitySupported(key: string): boolean {
if (!capabilities.value) return false
const detail = capabilities.value.capabilities?.find(capability => capability.key === key)
if (detail) return detail.status === 'supported'
if (capabilities.value.missing?.includes(key)) return false
return capabilities.value.supports?.[key] === true
}

function assertCapability(key: string): void {
if (!isCapabilitySupported(key)) {
throw new Error(`Kanban capability "${key}" is not supported by the current Hermes backend`)
}
}

function boardExists(board: string): boolean {
return activeBoards.value.some(item => item.slug === board)
}
Expand Down Expand Up @@ -257,6 +271,57 @@ export const useKanbanStore = defineStore('kanban', () => {
}
}

async function addComment(taskId: string, body: string, author?: string) {
assertCapability('commentsWrite')
return kanbanApi.addComment(taskId, { body, author }, { board: selectedBoard.value })
}

async function getTaskLog(taskId: string, tail?: number) {
assertCapability('taskLog')
return kanbanApi.getTaskLog(taskId, { board: selectedBoard.value, tail })
}

async function getDiagnostics(opts: Omit<KanbanDiagnosticsOptions, 'board'> = {}) {
assertCapability('diagnostics')
return kanbanApi.getDiagnostics({ ...opts, board: selectedBoard.value })
}

async function reclaimTask(taskId: string, reason?: string) {
assertCapability('reclaim')
const board = selectedBoard.value
const result = await kanbanApi.reclaimTask(taskId, { board, reason })
if (board === selectedBoard.value) await Promise.all([fetchTasks(true), fetchStats(), fetchBoards()])
return result
}

async function reassignTask(taskId: string, profile: string, opts: { reclaim?: boolean; reason?: string } = {}) {
assertCapability('reassign')
const board = selectedBoard.value
const result = await kanbanApi.reassignTask(taskId, profile, { board, ...opts })
if (board === selectedBoard.value) {
const task = tasks.value.find(t => t.id === taskId)
if (task) task.assignee = profile === 'none' ? null : profile
await Promise.all([fetchStats(), fetchAssignees()])
}
return result
}

async function specifyTask(taskId: string, author?: string) {
assertCapability('specify')
const board = selectedBoard.value
const result = await kanbanApi.specifyTask(taskId, { board, author })
if (board === selectedBoard.value) await Promise.all([fetchTasks(true), fetchStats(), fetchBoards()])
return result
}

async function dispatch(opts: Omit<KanbanDispatchOptions, 'board'> = {}) {
assertCapability('dispatch')
const board = selectedBoard.value
const result = await kanbanApi.dispatch({ ...opts, board })
if (board === selectedBoard.value) await Promise.all([fetchTasks(true), fetchStats(), fetchBoards()])
return result
}

function setFilter(key: 'status' | 'assignee', value: string | null) {
if (key === 'status') filterStatus.value = value
else filterAssignee.value = value
Expand All @@ -273,6 +338,7 @@ export const useKanbanStore = defineStore('kanban', () => {
boards,
capabilities,
activeBoards,
isCapabilitySupported,
loading,
boardsLoading,
boardWarning,
Expand All @@ -291,6 +357,13 @@ export const useKanbanStore = defineStore('kanban', () => {
blockTask,
unblockTasks,
assignTask,
addComment,
getTaskLog,
getDiagnostics,
reclaimTask,
reassignTask,
specifyTask,
dispatch,
setFilter,
setSelectedBoard,
recoverSelectedBoard,
Expand Down
Loading
Loading