kin: KIN-016 Агенты должны уметь говорить 'не могу'. Если агент не может выполнить задачу (нет доступа, не понимает, выходит за компетенцию) — он должен вернуть status: blocked с причиной, а не пытаться угадывать. PM при получении blocked от агента — эскалирует к человеку через GUI (уведомление) и Telegram (когда будет).
This commit is contained in:
parent
a605e9d110
commit
d9172fc17c
35 changed files with 2375 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
76
core/db.py
76
core/db.py
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
208
core/phases.py
Normal 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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue