kin: KIN-016 Агенты должны уметь говорить 'не могу'. Если агент не может выполнить задачу (нет доступа, не понимает, выходит за компетенцию) — он должен вернуть status: blocked с причиной, а не пытаться угадывать. PM при получении blocked от агента — эскалирует к человеку через GUI (уведомление) и Telegram (когда будет).

This commit is contained in:
Gros Frumos 2026-03-16 09:13:34 +02:00
parent a605e9d110
commit d9172fc17c
35 changed files with 2375 additions and 23 deletions

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import EscalationBanner from './components/EscalationBanner.vue'
</script>
<template>
@ -8,6 +9,7 @@
Kin
</router-link>
<nav class="flex items-center gap-4">
<EscalationBanner />
<router-link to="/settings" class="text-xs text-gray-400 hover:text-gray-200 no-underline">Settings</router-link>
<span class="text-xs text-gray-600">multi-agent orchestrator</span>
</nav>

View file

@ -49,6 +49,12 @@ export interface Project {
active_tasks: number
blocked_tasks: number
review_tasks: number
project_type: string | null
ssh_host: string | null
ssh_user: string | null
ssh_key_path: string | null
ssh_proxy_jump: string | null
description: string | null
}
export interface ObsidianSyncResult {
@ -148,6 +154,40 @@ export interface CostEntry {
total_duration_seconds: number
}
export interface Phase {
id: number
project_id: string
role: string
phase_order: number
status: string
task_id: string | null
revise_count: number
created_at: string
updated_at: string
task?: Task | null
}
export interface NewProjectPayload {
id: string
name: string
path: string
description: string
roles: string[]
tech_stack?: string[]
priority?: number
language?: string
project_type?: string
ssh_host?: string
ssh_user?: string
ssh_key_path?: string
ssh_proxy_jump?: string
}
export interface NewProjectResult {
project: Project
phases: Phase[]
}
export interface AuditItem {
id: string
reason: string
@ -163,6 +203,16 @@ export interface AuditResult {
error?: string
}
export interface EscalationNotification {
task_id: string
project_id: string
agent_role: string
reason: string
pipeline_step: string | null
blocked_at: string
telegram_sent: boolean
}
export const api = {
projects: () => get<Project[]>('/projects'),
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
@ -170,7 +220,7 @@ export const api = {
taskFull: (id: string) => get<TaskFull>(`/tasks/${id}/full`),
taskPipeline: (id: string) => get<PipelineStep[]>(`/tasks/${id}/pipeline`),
cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`),
createProject: (data: { id: string; name: string; path: string; tech_stack?: string[]; priority?: number }) =>
createProject: (data: { id: string; name: string; path?: string; tech_stack?: string[]; priority?: number; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string }) =>
post<Project>('/projects', data),
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string; category?: string }) =>
post<Task>('/tasks', data),
@ -192,7 +242,7 @@ export const api = {
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string }) =>
patch<Task>(`/tasks/${id}`, data),
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string }) =>
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string }) =>
patch<Project>(`/projects/${id}`, data),
deployProject: (projectId: string) =>
post<DeployResult>(`/projects/${projectId}/deploy`, {}),
@ -202,4 +252,16 @@ export const api = {
del<{ deleted: number }>(`/projects/${projectId}/decisions/${decisionId}`),
createDecision: (data: { project_id: string; type: string; title: string; description: string; category?: string; tags?: string[] }) =>
post<Decision>('/decisions', data),
newProject: (data: NewProjectPayload) =>
post<NewProjectResult>('/projects/new', data),
getPhases: (projectId: string) =>
get<Phase[]>(`/projects/${projectId}/phases`),
notifications: (projectId?: string) =>
get<EscalationNotification[]>(`/notifications${projectId ? `?project_id=${projectId}` : ''}`),
approvePhase: (phaseId: number, comment?: string) =>
post<{ phase: Phase; next_phase: Phase | null }>(`/phases/${phaseId}/approve`, { comment }),
rejectPhase: (phaseId: number, reason: string) =>
post<Phase>(`/phases/${phaseId}/reject`, { reason }),
revisePhase: (phaseId: number, comment: string) =>
post<{ phase: Phase; new_task: Task }>(`/phases/${phaseId}/revise`, { comment }),
}

View file

@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { api, type EscalationNotification } from '../api'
const STORAGE_KEY = 'kin_dismissed_escalations'
const notifications = ref<EscalationNotification[]>([])
const showPanel = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
function loadDismissed(): Set<string> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return new Set(raw ? JSON.parse(raw) : [])
} catch {
return new Set()
}
}
function saveDismissed(ids: Set<string>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]))
}
const dismissed = ref<Set<string>>(loadDismissed())
const visible = computed(() =>
notifications.value.filter(n => !dismissed.value.has(n.task_id))
)
async function load() {
try {
notifications.value = await api.notifications()
} catch {
// silent не ломаем layout при недоступном endpoint
}
}
function dismiss(taskId: string) {
dismissed.value = new Set([...dismissed.value, taskId])
saveDismissed(dismissed.value)
if (visible.value.length === 0) showPanel.value = false
}
function dismissAll() {
const newSet = new Set([...dismissed.value, ...visible.value.map(n => n.task_id)])
dismissed.value = newSet
saveDismissed(newSet)
showPanel.value = false
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch {
return iso
}
}
onMounted(async () => {
await load()
pollTimer = setInterval(load, 10000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<template>
<div class="relative">
<!-- Badge-кнопка видна только при наличии активных эскалаций -->
<button
v-if="visible.length > 0"
@click="showPanel = !showPanel"
class="relative flex items-center gap-1.5 px-2.5 py-1 text-xs bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900 transition-colors"
>
<span class="inline-block w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>
Эскалации
<span class="ml-0.5 font-bold">{{ visible.length }}</span>
</button>
<!-- Панель уведомлений -->
<div
v-if="showPanel && visible.length > 0"
class="absolute right-0 top-full mt-2 w-96 bg-gray-900 border border-red-900/60 rounded-lg shadow-2xl z-50"
>
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800">
<span class="text-xs font-semibold text-red-400">Эскалации требуется решение</span>
<div class="flex items-center gap-2">
<button
@click="dismissAll"
class="text-xs text-gray-500 hover:text-gray-300"
>Принять все</button>
<button @click="showPanel = false" class="text-gray-500 hover:text-gray-300 text-lg leading-none">&times;</button>
</div>
</div>
<div class="max-h-80 overflow-y-auto divide-y divide-gray-800">
<div
v-for="n in visible"
:key="n.task_id"
class="px-4 py-3"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-xs font-mono text-red-400 shrink-0">{{ n.task_id }}</span>
<span class="text-xs text-gray-500">·</span>
<span class="text-xs text-orange-400 shrink-0">{{ n.agent_role }}</span>
<span v-if="n.pipeline_step" class="text-xs text-gray-600 truncate">@ {{ n.pipeline_step }}</span>
</div>
<p class="text-xs text-gray-300 leading-snug break-words">{{ n.reason }}</p>
<p class="text-xs text-gray-600 mt-1">{{ formatTime(n.blocked_at) }}</p>
</div>
<button
@click="dismiss(n.task_id)"
class="shrink-0 px-2 py-1 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 hover:text-gray-200"
>Принято</button>
</div>
</div>
</div>
</div>
<!-- Overlay для закрытия панели -->
<div v-if="showPanel" class="fixed inset-0 z-40" @click="showPanel = false"></div>
</div>
</template>

View file

@ -11,7 +11,10 @@ const error = ref('')
// Add project modal
const showAdd = ref(false)
const form = ref({ id: '', name: '', path: '', tech_stack: '', priority: 5 })
const form = ref({
id: '', name: '', path: '', tech_stack: '', priority: 5,
project_type: 'development', ssh_host: '', ssh_user: 'root', ssh_key_path: '', ssh_proxy_jump: '',
})
const formError = ref('')
// Bootstrap modal
@ -20,6 +23,23 @@ const bsForm = ref({ id: '', name: '', path: '' })
const bsError = ref('')
const bsResult = ref('')
// New Project with Research modal
const RESEARCH_ROLES = [
{ key: 'business_analyst', label: 'Business Analyst', hint: 'бизнес-модель, аудитория, монетизация' },
{ key: 'market_researcher', label: 'Market Researcher', hint: 'конкуренты, ниша, сильные/слабые стороны' },
{ key: 'legal_researcher', label: 'Legal Researcher', hint: 'юрисдикция, лицензии, KYC/AML, GDPR' },
{ key: 'tech_researcher', label: 'Tech Researcher', hint: 'API, ограничения, стоимость, альтернативы' },
{ key: 'ux_designer', label: 'UX Designer', hint: 'анализ UX конкурентов, user journey, wireframes' },
{ key: 'marketer', label: 'Marketer', hint: 'стратегия продвижения, SEO, conversion-паттерны' },
]
const showNewProject = ref(false)
const npForm = ref({
id: '', name: '', path: '', description: '', tech_stack: '', priority: 5, language: 'ru',
})
const npRoles = ref<string[]>(['business_analyst', 'market_researcher', 'tech_researcher'])
const npError = ref('')
const npSaving = ref(false)
async function load() {
try {
loading.value = true
@ -66,11 +86,35 @@ function statusColor(s: string) {
async function addProject() {
formError.value = ''
if (form.value.project_type === 'operations' && !form.value.ssh_host) {
formError.value = 'SSH host is required for operations projects'
return
}
if (form.value.project_type !== 'operations' && !form.value.path) {
formError.value = 'Path is required'
return
}
try {
const ts = form.value.tech_stack ? form.value.tech_stack.split(',').map(s => s.trim()).filter(Boolean) : undefined
await api.createProject({ ...form.value, tech_stack: ts, priority: form.value.priority })
const payload: Parameters<typeof api.createProject>[0] = {
id: form.value.id,
name: form.value.name,
tech_stack: ts,
priority: form.value.priority,
project_type: form.value.project_type,
}
if (form.value.project_type !== 'operations') {
payload.path = form.value.path
} else {
payload.path = ''
if (form.value.ssh_host) payload.ssh_host = form.value.ssh_host
if (form.value.ssh_user) payload.ssh_user = form.value.ssh_user
if (form.value.ssh_key_path) payload.ssh_key_path = form.value.ssh_key_path
if (form.value.ssh_proxy_jump) payload.ssh_proxy_jump = form.value.ssh_proxy_jump
}
await api.createProject(payload)
showAdd.value = false
form.value = { id: '', name: '', path: '', tech_stack: '', priority: 5 }
form.value = { id: '', name: '', path: '', tech_stack: '', priority: 5, project_type: 'development', ssh_host: '', ssh_user: 'root', ssh_key_path: '', ssh_proxy_jump: '' }
await load()
} catch (e: any) {
formError.value = e.message
@ -88,6 +132,42 @@ async function runBootstrap() {
bsError.value = e.message
}
}
function toggleNpRole(key: string) {
const idx = npRoles.value.indexOf(key)
if (idx >= 0) npRoles.value.splice(idx, 1)
else npRoles.value.push(key)
}
async function createNewProject() {
npError.value = ''
if (!npRoles.value.length) {
npError.value = 'Выберите хотя бы одну роль'
return
}
npSaving.value = true
try {
const ts = npForm.value.tech_stack ? npForm.value.tech_stack.split(',').map(s => s.trim()).filter(Boolean) : undefined
await api.newProject({
id: npForm.value.id,
name: npForm.value.name,
path: npForm.value.path,
description: npForm.value.description,
roles: npRoles.value,
tech_stack: ts,
priority: npForm.value.priority,
language: npForm.value.language,
})
showNewProject.value = false
npForm.value = { id: '', name: '', path: '', description: '', tech_stack: '', priority: 5, language: 'ru' }
npRoles.value = ['business_analyst', 'market_researcher', 'tech_researcher']
await load()
} catch (e: any) {
npError.value = e.message
} finally {
npSaving.value = false
}
}
</script>
<template>
@ -102,9 +182,13 @@ async function runBootstrap() {
class="px-3 py-1.5 text-xs bg-purple-900/50 text-purple-400 border border-purple-800 rounded hover:bg-purple-900">
Bootstrap
</button>
<button @click="showNewProject = true"
class="px-3 py-1.5 text-xs bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900">
+ New Project
</button>
<button @click="showAdd = true"
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
+ Project
+ Blank
</button>
</div>
</div>
@ -122,6 +206,9 @@ async function runBootstrap() {
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-gray-200">{{ p.id }}</span>
<Badge :text="p.status" :color="statusColor(p.status)" />
<Badge v-if="p.project_type && p.project_type !== 'development'"
:text="p.project_type"
:color="p.project_type === 'operations' ? 'orange' : 'green'" />
<span class="text-sm text-gray-400">{{ p.name }}</span>
</div>
<div class="flex items-center gap-3 text-xs text-gray-500">
@ -152,8 +239,39 @@ async function runBootstrap() {
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<input v-model="form.name" placeholder="Name" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<input v-model="form.path" placeholder="Path (e.g. ~/projects/myproj)" required
<!-- Project type selector -->
<div>
<p class="text-xs text-gray-500 mb-1.5">Тип проекта:</p>
<div class="flex gap-2">
<button v-for="t in ['development', 'operations', 'research']" :key="t"
type="button"
@click="form.project_type = t"
class="flex-1 py-1.5 text-xs border rounded transition-colors"
:class="form.project_type === t
? t === 'development' ? 'bg-blue-900/40 text-blue-300 border-blue-700'
: t === 'operations' ? 'bg-orange-900/40 text-orange-300 border-orange-700'
: 'bg-green-900/40 text-green-300 border-green-700'
: 'bg-gray-900 text-gray-500 border-gray-800 hover:text-gray-300 hover:border-gray-600'"
>{{ t }}</button>
</div>
</div>
<!-- Path (development / research) -->
<input v-if="form.project_type !== 'operations'"
v-model="form.path" placeholder="Path (e.g. ~/projects/myproj)"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<!-- SSH fields (operations) -->
<template v-if="form.project_type === 'operations'">
<input v-model="form.ssh_host" placeholder="SSH host (e.g. 192.168.1.1)" required
class="w-full bg-gray-800 border border-orange-800/60 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<div class="grid grid-cols-2 gap-2">
<input v-model="form.ssh_user" placeholder="SSH user (e.g. root)"
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<input v-model="form.ssh_key_path" placeholder="Key path (e.g. ~/.ssh/id_rsa)"
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
</div>
<input v-model="form.ssh_proxy_jump" placeholder="ProxyJump (optional, e.g. jumpt)"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
</template>
<input v-model="form.tech_stack" placeholder="Tech stack (comma-separated)"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<input v-model.number="form.priority" type="number" min="1" max="10" placeholder="Priority (1-10)"
@ -166,6 +284,52 @@ async function runBootstrap() {
</form>
</Modal>
<!-- New Project with Research Modal -->
<Modal v-if="showNewProject" title="New Project — Start Research" @close="showNewProject = false">
<form @submit.prevent="createNewProject" class="space-y-3">
<div class="grid grid-cols-2 gap-2">
<input v-model="npForm.id" placeholder="ID (e.g. myapp)" required
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<input v-model="npForm.name" placeholder="Name" required
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
</div>
<input v-model="npForm.path" placeholder="Path (e.g. ~/projects/myapp)"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<textarea v-model="npForm.description" placeholder="Описание проекта (свободный текст для агентов)" required rows="4"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-none"></textarea>
<input v-model="npForm.tech_stack" placeholder="Tech stack (comma-separated, optional)"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<div>
<p class="text-xs text-gray-500 mb-2">Этапы research (Architect добавляется автоматически последним):</p>
<div class="space-y-1.5">
<label v-for="r in RESEARCH_ROLES" :key="r.key"
class="flex items-start gap-2 cursor-pointer group">
<input type="checkbox"
:checked="npRoles.includes(r.key)"
@change="toggleNpRole(r.key)"
class="mt-0.5 accent-green-500 cursor-pointer" />
<div>
<span class="text-sm text-gray-300 group-hover:text-gray-100">{{ r.label }}</span>
<span class="text-xs text-gray-600 ml-1"> {{ r.hint }}</span>
</div>
</label>
<label class="flex items-start gap-2 opacity-50">
<input type="checkbox" checked disabled class="mt-0.5" />
<div>
<span class="text-sm text-gray-400">Architect</span>
<span class="text-xs text-gray-600 ml-1"> blueprint на основе одобренных исследований</span>
</div>
</label>
</div>
</div>
<p v-if="npError" class="text-red-400 text-xs">{{ npError }}</p>
<button type="submit" :disabled="npSaving"
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900 disabled:opacity-50">
{{ npSaving ? 'Starting...' : 'Start Research' }}
</button>
</form>
</Modal>
<!-- Bootstrap Modal -->
<Modal v-if="showBootstrap" title="Bootstrap Project" @close="showBootstrap = false">
<form @submit.prevent="runBootstrap" class="space-y-3">

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type ProjectDetail, type AuditResult } from '../api'
import { api, type ProjectDetail, type AuditResult, type Phase } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
@ -12,7 +12,93 @@ const router = useRouter()
const project = ref<ProjectDetail | null>(null)
const loading = ref(true)
const error = ref('')
const activeTab = ref<'tasks' | 'decisions' | 'modules'>('tasks')
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules'>('tasks')
// Phases
const phases = ref<Phase[]>([])
const phasesLoading = ref(false)
const phaseError = ref('')
const showReviseModal = ref(false)
const revisePhaseId = ref<number | null>(null)
const reviseComment = ref('')
const reviseError = ref('')
const reviseSaving = ref(false)
const showRejectModal = ref(false)
const rejectPhaseId = ref<number | null>(null)
const rejectReason = ref('')
const rejectError = ref('')
const rejectSaving = ref(false)
async function loadPhases() {
phasesLoading.value = true
phaseError.value = ''
try {
phases.value = await api.getPhases(props.id)
} catch (e: any) {
phaseError.value = e.message
} finally {
phasesLoading.value = false
}
}
async function approvePhase(phaseId: number) {
try {
await api.approvePhase(phaseId)
await loadPhases()
} catch (e: any) {
phaseError.value = e.message
}
}
function openRevise(phaseId: number) {
revisePhaseId.value = phaseId
reviseComment.value = ''
reviseError.value = ''
showReviseModal.value = true
}
async function submitRevise() {
if (!reviseComment.value.trim()) { reviseError.value = 'Comment required'; return }
reviseSaving.value = true
try {
await api.revisePhase(revisePhaseId.value!, reviseComment.value)
showReviseModal.value = false
await loadPhases()
} catch (e: any) {
reviseError.value = e.message
} finally {
reviseSaving.value = false
}
}
function openReject(phaseId: number) {
rejectPhaseId.value = phaseId
rejectReason.value = ''
rejectError.value = ''
showRejectModal.value = true
}
async function submitReject() {
if (!rejectReason.value.trim()) { rejectError.value = 'Reason required'; return }
rejectSaving.value = true
try {
await api.rejectPhase(rejectPhaseId.value!, rejectReason.value)
showRejectModal.value = false
await loadPhases()
} catch (e: any) {
rejectError.value = e.message
} finally {
rejectSaving.value = false
}
}
function phaseStatusColor(s: string) {
const m: Record<string, string> = {
pending: 'gray', active: 'blue', approved: 'green',
rejected: 'red', revising: 'yellow',
}
return m[s] || 'gray'
}
// Filters
const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'cancelled']
@ -149,7 +235,12 @@ watch(selectedStatuses, (val) => {
router.replace({ query: { ...route.query, status: val.length ? val.join(',') : undefined } })
}, { deep: true })
onMounted(async () => { await load(); loadMode(); loadAutocommit() })
onMounted(async () => {
await load()
loadMode()
loadAutocommit()
await loadPhases()
})
const taskCategories = computed(() => {
if (!project.value) return []
@ -288,16 +379,29 @@ async function addDecision() {
<h1 class="text-xl font-bold text-gray-100">{{ project.id }}</h1>
<span class="text-gray-400">{{ project.name }}</span>
<Badge :text="project.status" :color="project.status === 'active' ? 'green' : 'gray'" />
<Badge v-if="project.project_type && project.project_type !== 'development'"
:text="project.project_type"
:color="project.project_type === 'operations' ? 'orange' : 'green'" />
</div>
<div class="flex gap-2 flex-wrap mb-2" v-if="project.tech_stack?.length">
<Badge v-for="t in project.tech_stack" :key="t" :text="t" color="purple" />
</div>
<p class="text-xs text-gray-600">{{ project.path }}</p>
<!-- Path (development / research) -->
<p v-if="project.project_type !== 'operations'" class="text-xs text-gray-600">{{ project.path }}</p>
<!-- SSH info (operations) -->
<div v-if="project.project_type === 'operations'" class="flex flex-wrap gap-3 text-xs text-gray-600">
<span v-if="project.ssh_host">
<span class="text-gray-500">SSH:</span>
<span class="text-orange-400 ml-1">{{ project.ssh_user || 'root' }}@{{ project.ssh_host }}</span>
</span>
<span v-if="project.ssh_key_path">key: {{ project.ssh_key_path }}</span>
<span v-if="project.ssh_proxy_jump">via: {{ project.ssh_proxy_jump }}</span>
</div>
</div>
<!-- Tabs -->
<div class="flex gap-1 mb-4 border-b border-gray-800">
<button v-for="tab in (['tasks', 'decisions', 'modules'] as const)" :key="tab"
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules'] as const)" :key="tab"
@click="activeTab = tab"
class="px-4 py-2 text-sm border-b-2 transition-colors"
:class="activeTab === tab
@ -306,6 +410,7 @@ async function addDecision() {
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
<span class="text-xs text-gray-600 ml-1">
{{ tab === 'tasks' ? project.tasks.length
: tab === 'phases' ? phases.length
: tab === 'decisions' ? project.decisions.length
: project.modules.length }}
</span>
@ -449,6 +554,83 @@ async function addDecision() {
</div>
</div>
<!-- Phases Tab -->
<div v-if="activeTab === 'phases'">
<p v-if="phasesLoading" class="text-gray-500 text-sm">Loading phases...</p>
<p v-else-if="phaseError" class="text-red-400 text-sm">{{ phaseError }}</p>
<div v-else-if="phases.length === 0" class="text-gray-600 text-sm">
No research phases. Use "New Project" to start a research workflow.
</div>
<div v-else class="space-y-2">
<div v-for="ph in phases" :key="ph.id"
class="px-4 py-3 border rounded transition-colors"
:class="{
'border-blue-700 bg-blue-950/20': ph.status === 'active',
'border-green-800 bg-green-950/10': ph.status === 'approved',
'border-red-800 bg-red-950/10': ph.status === 'rejected',
'border-yellow-700 bg-yellow-950/10': ph.status === 'revising',
'border-gray-800': ph.status === 'pending',
}">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-600 w-5 text-right">{{ ph.phase_order + 1 }}.</span>
<Badge :text="ph.status" :color="phaseStatusColor(ph.status)" />
<span class="text-sm font-medium text-gray-200">{{ ph.role.replace(/_/g, ' ') }}</span>
<span v-if="ph.revise_count" class="text-xs text-yellow-500">(revise ×{{ ph.revise_count }})</span>
</div>
<div class="flex items-center gap-2">
<router-link v-if="ph.task_id"
:to="`/task/${ph.task_id}`"
class="text-xs text-blue-400 hover:text-blue-300 no-underline">
{{ ph.task_id }}
</router-link>
<template v-if="ph.status === 'active'">
<button @click="approvePhase(ph.id)"
class="px-2 py-0.5 text-xs bg-green-900/40 text-green-400 border border-green-800 rounded hover:bg-green-900">
Approve
</button>
<button @click="openRevise(ph.id)"
class="px-2 py-0.5 text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded hover:bg-yellow-900">
Revise
</button>
<button @click="openReject(ph.id)"
class="px-2 py-0.5 text-xs bg-red-900/40 text-red-400 border border-red-800 rounded hover:bg-red-900">
Reject
</button>
</template>
</div>
</div>
<div v-if="ph.task?.title" class="mt-1 text-xs text-gray-500 ml-7">{{ ph.task.title }}</div>
</div>
</div>
<!-- Revise Modal -->
<Modal v-if="showReviseModal" title="Request Revision" @close="showReviseModal = false">
<div class="space-y-3">
<textarea v-model="reviseComment" placeholder="Что нужно доработать?" rows="4"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-none"></textarea>
<p v-if="reviseError" class="text-red-400 text-xs">{{ reviseError }}</p>
<button @click="submitRevise" :disabled="reviseSaving"
class="w-full py-2 bg-yellow-900/50 text-yellow-400 border border-yellow-800 rounded text-sm hover:bg-yellow-900 disabled:opacity-50">
{{ reviseSaving ? 'Saving...' : 'Send for Revision' }}
</button>
</div>
</Modal>
<!-- Reject Modal -->
<Modal v-if="showRejectModal" title="Reject Phase" @close="showRejectModal = false">
<div class="space-y-3">
<textarea v-model="rejectReason" placeholder="Причина отклонения" rows="3"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-none"></textarea>
<p v-if="rejectError" class="text-red-400 text-xs">{{ rejectError }}</p>
<button @click="submitReject" :disabled="rejectSaving"
class="w-full py-2 bg-red-900/50 text-red-400 border border-red-800 rounded text-sm hover:bg-red-900 disabled:opacity-50">
{{ rejectSaving ? 'Saving...' : 'Reject' }}
</button>
</div>
</Modal>
</div>
<!-- Decisions Tab -->
<div v-if="activeTab === 'decisions'">
<div class="flex items-center justify-between mb-3">