Add follow-up task generation on approve

When approving a task, PM agent analyzes pipeline output and creates
follow-up tasks automatically (e.g. security audit → 8 fix tasks).

core/followup.py:
  generate_followups() — collects pipeline output, runs followup agent,
  parses JSON task list, creates tasks with parent_task_id linkage.
  Handles: bare arrays, {tasks:[...]} wrappers, invalid JSON, empty.

agents/prompts/followup.md — PM prompt for analyzing results and
  creating actionable follow-up tasks with priority from severity.

CLI: kin approve <task_id> [--followup] [--decision "text"]
API: POST /api/tasks/{id}/approve {create_followups: true}
  Returns {status, decision, followup_tasks: [...]}

Frontend (TaskDetail approve modal):
  - Checkbox "Create follow-up tasks" (default ON)
  - Loading state during generation
  - Results view: list of created tasks with links to /task/:id

ProjectView: tasks show "from VDOL-001" for follow-ups.

13 new tests (followup), 125 total, all passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
johnfrum1234 2026-03-15 15:02:58 +02:00
parent f7830d484c
commit 9264415776
8 changed files with 426 additions and 17 deletions

View file

@ -181,11 +181,14 @@ class TaskApprove(BaseModel):
decision_title: str | None = None
decision_description: str | None = None
decision_type: str = "decision"
create_followups: bool = False
@app.post("/api/tasks/{task_id}/approve")
def approve_task(task_id: str, body: TaskApprove | None = None):
"""Approve a task: set status=done, optionally add a decision."""
"""Approve a task: set status=done, optionally add decision and create follow-ups."""
from core.followup import generate_followups
conn = get_conn()
t = models.get_task(conn, task_id)
if not t:
@ -199,8 +202,15 @@ def approve_task(task_id: str, body: TaskApprove | None = None):
body.decision_title, body.decision_description or body.decision_title,
task_id=task_id,
)
followup_tasks = []
if body and body.create_followups:
followup_tasks = generate_followups(conn, task_id)
conn.close()
return {"status": "done", "decision": decision}
return {
"status": "done",
"decision": decision,
"followup_tasks": followup_tasks,
}
class TaskReject(BaseModel):

View file

@ -112,8 +112,8 @@ export const api = {
post<Project>('/projects', data),
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string }) =>
post<Task>('/tasks', data),
approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string }) =>
post<{ status: string }>(`/tasks/${id}/approve`, data || {}),
approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string; create_followups?: boolean }) =>
post<{ status: string; followup_tasks: Task[] }>(`/tasks/${id}/approve`, data || {}),
rejectTask: (id: string, reason: string) =>
post<{ status: string }>(`/tasks/${id}/reject`, { reason }),
runTask: (id: string) =>

View file

@ -197,6 +197,7 @@ async function addDecision() {
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
<span class="text-gray-300 truncate">{{ t.title }}</span>
<span v-if="t.parent_task_id" class="text-[10px] text-gray-600 shrink-0">from {{ t.parent_task_id }}</span>
</div>
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
<span v-if="t.assigned_role">{{ t.assigned_role }}</span>

View file

@ -15,7 +15,9 @@ let pollTimer: ReturnType<typeof setInterval> | null = null
// Approve modal
const showApprove = ref(false)
const approveForm = ref({ title: '', description: '', type: 'decision' })
const approveForm = ref({ title: '', description: '', type: 'decision', createFollowups: true })
const approveLoading = ref(false)
const followupResults = ref<{ id: string; title: string }[]>([])
// Reject modal
const showReject = ref(false)
@ -74,16 +76,29 @@ function formatOutput(text: string | null): string {
async function approve() {
if (!task.value) return
approveLoading.value = true
followupResults.value = []
try {
const data = approveForm.value.title
? { decision_title: approveForm.value.title, decision_description: approveForm.value.description, decision_type: approveForm.value.type }
: undefined
await api.approveTask(props.id, data)
showApprove.value = false
approveForm.value = { title: '', description: '', type: 'decision' }
const data: Record<string, unknown> = {
create_followups: approveForm.value.createFollowups,
}
if (approveForm.value.title) {
data.decision_title = approveForm.value.title
data.decision_description = approveForm.value.description
data.decision_type = approveForm.value.type
}
const res = await api.approveTask(props.id, data as any)
if (res.followup_tasks?.length) {
followupResults.value = res.followup_tasks.map(t => ({ id: t.id, title: t.title }))
} else {
showApprove.value = false
}
approveForm.value = { title: '', description: '', type: 'decision', createFollowups: true }
await load()
} catch (e: any) {
error.value = e.message
} finally {
approveLoading.value = false
}
}
@ -227,16 +242,36 @@ const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
</div>
<!-- Approve Modal -->
<Modal v-if="showApprove" title="Approve Task" @close="showApprove = false">
<form @submit.prevent="approve" class="space-y-3">
<p class="text-sm text-gray-400">Optionally record a decision from this task:</p>
<Modal v-if="showApprove" title="Approve Task" @close="showApprove = false; followupResults = []">
<!-- Follow-up results -->
<div v-if="followupResults.length" class="space-y-3">
<p class="text-sm text-green-400">Task approved. Created {{ followupResults.length }} follow-up tasks:</p>
<div class="space-y-1">
<router-link v-for="f in followupResults" :key="f.id" :to="`/task/${f.id}`"
class="block px-3 py-2 border border-gray-800 rounded text-sm text-gray-300 hover:border-gray-600 no-underline">
<span class="text-gray-500">{{ f.id }}</span> {{ f.title }}
</router-link>
</div>
<button @click="showApprove = false; followupResults = []"
class="w-full py-2 bg-gray-800 text-gray-300 border border-gray-700 rounded text-sm hover:bg-gray-700">
Close
</button>
</div>
<!-- Approve form -->
<form v-else @submit.prevent="approve" class="space-y-3">
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" v-model="approveForm.createFollowups"
class="rounded border-gray-600 bg-gray-800 text-blue-500" />
Create follow-up tasks from pipeline results
</label>
<p class="text-xs text-gray-500">Optionally record a decision:</p>
<input v-model="approveForm.title" placeholder="Decision title (optional)"
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-if="approveForm.title" v-model="approveForm.description" placeholder="Description"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y" rows="2"></textarea>
<button type="submit"
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900">
Approve &amp; mark done
<button type="submit" :disabled="approveLoading"
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">
{{ approveLoading ? 'Processing...' : 'Approve &amp; mark done' }}
</button>
</form>
</Modal>