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

@ -84,6 +84,10 @@ def build_context(
conn, project_id, types=["convention"],
)
elif role == "sysadmin":
ctx["decisions"] = models.get_decisions(conn, project_id)
ctx["modules"] = models.get_modules(conn, project_id)
elif role == "tester":
# Minimal context — just the task spec
pass
@ -118,14 +122,22 @@ def _slim_task(task: dict) -> dict:
def _slim_project(project: dict) -> dict:
"""Extract only relevant fields from a project."""
return {
result = {
"id": project["id"],
"name": project["name"],
"path": project["path"],
"tech_stack": project.get("tech_stack"),
"language": project.get("language", "ru"),
"execution_mode": project.get("execution_mode"),
"project_type": project.get("project_type", "development"),
}
# Include SSH fields for operations projects
if project.get("project_type") == "operations":
result["ssh_host"] = project.get("ssh_host")
result["ssh_user"] = project.get("ssh_user")
result["ssh_key_path"] = project.get("ssh_key_path")
result["ssh_proxy_jump"] = project.get("ssh_proxy_jump")
return result
def _extract_module_hint(task: dict | None) -> str | None:
@ -159,6 +171,25 @@ def format_prompt(context: dict, role: str, prompt_template: str | None = None)
if proj.get("tech_stack"):
sections.append(f"Tech stack: {', '.join(proj['tech_stack'])}")
sections.append(f"Path: {proj['path']}")
project_type = proj.get("project_type", "development")
sections.append(f"Project type: {project_type}")
sections.append("")
# SSH connection info for operations projects
if proj and proj.get("project_type") == "operations":
ssh_host = proj.get("ssh_host") or ""
ssh_user = proj.get("ssh_user") or ""
ssh_key = proj.get("ssh_key_path") or ""
ssh_proxy = proj.get("ssh_proxy_jump") or ""
sections.append("## SSH Connection")
if ssh_host:
sections.append(f"Host: {ssh_host}")
if ssh_user:
sections.append(f"User: {ssh_user}")
if ssh_key:
sections.append(f"Key: {ssh_key}")
if ssh_proxy:
sections.append(f"ProxyJump: {ssh_proxy}")
sections.append("")
# Task info

View file

@ -23,6 +23,12 @@ CREATE TABLE IF NOT EXISTS projects (
language TEXT DEFAULT 'ru',
execution_mode TEXT NOT NULL DEFAULT 'review',
deploy_command TEXT,
project_type TEXT DEFAULT 'development',
ssh_host TEXT,
ssh_user TEXT,
ssh_key_path TEXT,
ssh_proxy_jump TEXT,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@ -43,6 +49,9 @@ CREATE TABLE IF NOT EXISTS tasks (
forgejo_issue_id INTEGER,
execution_mode TEXT,
blocked_reason TEXT,
blocked_at DATETIME,
blocked_agent_role TEXT,
blocked_pipeline_step TEXT,
dangerously_skipped BOOLEAN DEFAULT 0,
revise_comment TEXT,
category TEXT DEFAULT NULL,
@ -95,6 +104,21 @@ CREATE TABLE IF NOT EXISTS modules (
UNIQUE(project_id, name)
);
-- Фазы исследования нового проекта (research workflow KIN-059)
CREATE TABLE IF NOT EXISTS project_phases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id),
role TEXT NOT NULL,
phase_order INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
task_id TEXT REFERENCES tasks(id),
revise_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_phases_project ON project_phases(project_id, phase_order);
-- Pipelines (история запусков)
CREATE TABLE IF NOT EXISTS pipelines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -250,6 +274,16 @@ def _migrate(conn: sqlite3.Connection):
conn.execute("ALTER TABLE tasks ADD COLUMN category TEXT DEFAULT NULL")
conn.commit()
if "blocked_at" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN blocked_at DATETIME")
conn.commit()
if "blocked_agent_role" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN blocked_agent_role TEXT")
conn.commit()
if "blocked_pipeline_step" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN blocked_pipeline_step TEXT")
conn.commit()
if "obsidian_vault_path" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT")
conn.commit()
@ -258,10 +292,50 @@ def _migrate(conn: sqlite3.Connection):
conn.execute("ALTER TABLE projects ADD COLUMN deploy_command TEXT")
conn.commit()
# Migrate audit_log table (KIN-021)
if "project_type" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN project_type TEXT DEFAULT 'development'")
conn.commit()
if "ssh_host" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN ssh_host TEXT")
conn.commit()
if "ssh_user" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN ssh_user TEXT")
conn.commit()
if "ssh_key_path" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN ssh_key_path TEXT")
conn.commit()
if "ssh_proxy_jump" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN ssh_proxy_jump TEXT")
conn.commit()
if "description" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN description TEXT")
conn.commit()
# Migrate audit_log + project_phases tables
existing_tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "project_phases" not in existing_tables:
conn.executescript("""
CREATE TABLE IF NOT EXISTS project_phases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id),
role TEXT NOT NULL,
phase_order INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
task_id TEXT REFERENCES tasks(id),
revise_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_phases_project ON project_phases(project_id, phase_order);
""")
conn.commit()
if "audit_log" not in existing_tables:
conn.executescript("""
CREATE TABLE IF NOT EXISTS audit_log (

View file

@ -72,14 +72,22 @@ def create_project(
forgejo_repo: str | None = None,
language: str = "ru",
execution_mode: str = "review",
project_type: str = "development",
ssh_host: str | None = None,
ssh_user: str | None = None,
ssh_key_path: str | None = None,
ssh_proxy_jump: str | None = None,
description: str | None = None,
) -> dict:
"""Create a new project and return it as dict."""
conn.execute(
"""INSERT INTO projects (id, name, path, tech_stack, status, priority,
pm_prompt, claude_md_path, forgejo_repo, language, execution_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
pm_prompt, claude_md_path, forgejo_repo, language, execution_mode,
project_type, ssh_host, ssh_user, ssh_key_path, ssh_proxy_jump, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(id, name, path, _json_encode(tech_stack), status, priority,
pm_prompt, claude_md_path, forgejo_repo, language, execution_mode),
pm_prompt, claude_md_path, forgejo_repo, language, execution_mode,
project_type, ssh_host, ssh_user, ssh_key_path, ssh_proxy_jump, description),
)
conn.commit()
return get_project(conn, id)
@ -612,3 +620,55 @@ def get_cost_summary(conn: sqlite3.Connection, days: int = 7) -> list[dict]:
ORDER BY total_cost_usd DESC
""", (f"-{days} days",)).fetchall()
return _rows_to_list(rows)
# ---------------------------------------------------------------------------
# Project Phases (KIN-059)
# ---------------------------------------------------------------------------
def create_phase(
conn: sqlite3.Connection,
project_id: str,
role: str,
phase_order: int,
) -> dict:
"""Create a research phase for a project."""
cur = conn.execute(
"""INSERT INTO project_phases (project_id, role, phase_order, status)
VALUES (?, ?, ?, 'pending')""",
(project_id, role, phase_order),
)
conn.commit()
row = conn.execute(
"SELECT * FROM project_phases WHERE id = ?", (cur.lastrowid,)
).fetchone()
return _row_to_dict(row)
def get_phase(conn: sqlite3.Connection, phase_id: int) -> dict | None:
"""Get a project phase by id."""
row = conn.execute(
"SELECT * FROM project_phases WHERE id = ?", (phase_id,)
).fetchone()
return _row_to_dict(row)
def list_phases(conn: sqlite3.Connection, project_id: str) -> list[dict]:
"""List all phases for a project ordered by phase_order."""
rows = conn.execute(
"SELECT * FROM project_phases WHERE project_id = ? ORDER BY phase_order",
(project_id,),
).fetchall()
return _rows_to_list(rows)
def update_phase(conn: sqlite3.Connection, phase_id: int, **fields) -> dict:
"""Update phase fields. Auto-sets updated_at."""
if not fields:
return get_phase(conn, phase_id)
fields["updated_at"] = datetime.now().isoformat()
sets = ", ".join(f"{k} = ?" for k in fields)
vals = list(fields.values()) + [phase_id]
conn.execute(f"UPDATE project_phases SET {sets} WHERE id = ?", vals)
conn.commit()
return get_phase(conn, phase_id)

208
core/phases.py Normal file
View file

@ -0,0 +1,208 @@
"""
Kin Research Phase Pipeline (KIN-059).
Sequential workflow: Director describes a new project, picks researcher roles,
each phase produces a task for review. After approve next phase activates.
Architect always runs last (auto-added when any researcher is selected).
"""
import sqlite3
from core import models
# Canonical order of research roles (architect always last)
RESEARCH_ROLES = [
"business_analyst",
"market_researcher",
"legal_researcher",
"tech_researcher",
"ux_designer",
"marketer",
"architect",
]
# Human-readable labels
ROLE_LABELS = {
"business_analyst": "Business Analyst",
"market_researcher": "Market Researcher",
"legal_researcher": "Legal Researcher",
"tech_researcher": "Tech Researcher",
"ux_designer": "UX Designer",
"marketer": "Marketer",
"architect": "Architect",
}
def validate_roles(roles: list[str]) -> list[str]:
"""Filter unknown roles, remove duplicates, strip 'architect' (auto-added later)."""
seen: set[str] = set()
result = []
for r in roles:
r = r.strip().lower()
if r == "architect":
continue
if r in RESEARCH_ROLES and r not in seen:
seen.add(r)
result.append(r)
return result
def build_phase_order(selected_roles: list[str]) -> list[str]:
"""Return roles in canonical RESEARCH_ROLES order, append architect if any selected."""
ordered = [r for r in RESEARCH_ROLES if r in selected_roles and r != "architect"]
if ordered:
ordered.append("architect")
return ordered
def create_project_with_phases(
conn: sqlite3.Connection,
id: str,
name: str,
path: str,
description: str,
selected_roles: list[str],
tech_stack: list | None = None,
priority: int = 5,
language: str = "ru",
) -> dict:
"""Create project + sequential research phases.
Returns {project, phases}.
"""
clean_roles = validate_roles(selected_roles)
ordered_roles = build_phase_order(clean_roles)
if not ordered_roles:
raise ValueError("At least one research role must be selected")
project = models.create_project(
conn, id, name, path,
tech_stack=tech_stack, priority=priority, language=language,
description=description,
)
phases = []
for idx, role in enumerate(ordered_roles):
phase = models.create_phase(conn, id, role, idx)
phases.append(phase)
# Activate the first phase immediately
if phases:
phases[0] = activate_phase(conn, phases[0]["id"])
return {"project": project, "phases": phases}
def activate_phase(conn: sqlite3.Connection, phase_id: int) -> dict:
"""Create a task for the phase and set it to active.
Task brief includes project description + phase context.
"""
phase = models.get_phase(conn, phase_id)
if not phase:
raise ValueError(f"Phase {phase_id} not found")
project = models.get_project(conn, phase["project_id"])
if not project:
raise ValueError(f"Project {phase['project_id']} not found")
task_id = models.next_task_id(conn, phase["project_id"], category=None)
brief = {
"text": project.get("description") or project["name"],
"phase": phase["role"],
"phase_order": phase["phase_order"],
"workflow": "research",
}
task = models.create_task(
conn, task_id, phase["project_id"],
title=f"[Research] {ROLE_LABELS.get(phase['role'], phase['role'])}",
assigned_role=phase["role"],
brief=brief,
status="pending",
category=None,
)
updated = models.update_phase(conn, phase_id, task_id=task["id"], status="active")
return updated
def approve_phase(conn: sqlite3.Connection, phase_id: int) -> dict:
"""Approve a phase, activate the next one (or finish workflow).
Returns {phase, next_phase|None}.
"""
phase = models.get_phase(conn, phase_id)
if not phase:
raise ValueError(f"Phase {phase_id} not found")
if phase["status"] != "active":
raise ValueError(f"Phase {phase_id} is not active (current: {phase['status']})")
updated = models.update_phase(conn, phase_id, status="approved")
# Find next pending phase
all_phases = models.list_phases(conn, phase["project_id"])
next_phase = None
for p in all_phases:
if p["phase_order"] > phase["phase_order"] and p["status"] == "pending":
next_phase = p
break
if next_phase:
activated = activate_phase(conn, next_phase["id"])
return {"phase": updated, "next_phase": activated}
return {"phase": updated, "next_phase": None}
def reject_phase(conn: sqlite3.Connection, phase_id: int, reason: str) -> dict:
"""Reject a phase (director rejects the research output entirely)."""
phase = models.get_phase(conn, phase_id)
if not phase:
raise ValueError(f"Phase {phase_id} not found")
if phase["status"] != "active":
raise ValueError(f"Phase {phase_id} is not active (current: {phase['status']})")
return models.update_phase(conn, phase_id, status="rejected")
def revise_phase(conn: sqlite3.Connection, phase_id: int, comment: str) -> dict:
"""Request revision: create a new task for the same role with the comment.
Returns {phase, new_task}.
"""
phase = models.get_phase(conn, phase_id)
if not phase:
raise ValueError(f"Phase {phase_id} not found")
if phase["status"] not in ("active", "revising"):
raise ValueError(
f"Phase {phase_id} cannot be revised (current: {phase['status']})"
)
project = models.get_project(conn, phase["project_id"])
if not project:
raise ValueError(f"Project {phase['project_id']} not found")
new_task_id = models.next_task_id(conn, phase["project_id"], category=None)
brief = {
"text": project.get("description") or project["name"],
"phase": phase["role"],
"phase_order": phase["phase_order"],
"workflow": "research",
"revise_comment": comment,
"revise_count": (phase.get("revise_count") or 0) + 1,
}
new_task = models.create_task(
conn, new_task_id, phase["project_id"],
title=f"[Research Revise] {ROLE_LABELS.get(phase['role'], phase['role'])}",
assigned_role=phase["role"],
brief=brief,
status="pending",
category=None,
)
new_revise_count = (phase.get("revise_count") or 0) + 1
updated = models.update_phase(
conn, phase_id,
status="revising",
task_id=new_task["id"],
revise_count=new_revise_count,
)
return {"phase": updated, "new_task": new_task}