diff --git a/agents/prompts/architect.md b/agents/prompts/architect.md index 47b377f..5cee75b 100644 --- a/agents/prompts/architect.md +++ b/agents/prompts/architect.md @@ -66,6 +66,83 @@ Valid values for `status`: `"done"`, `"blocked"`. If status is "blocked", include `"blocked_reason": "..."`. +## Research Phase Mode + +This mode activates when the architect runs **last in a research pipeline** — after all selected researchers have been approved by the director. + +### Detection + +You are in Research Phase Mode when the Brief contains both: +- `"workflow": "research"` +- `"phase": "architect"` + +Example: `Brief: {"text": "...", "phase": "architect", "workflow": "research", "phases_context": {...}}` + +### Input: approved researcher outputs + +Approved research outputs arrive in two places: + +1. **`brief.phases_context`** — dict keyed by researcher role name, each value is the full JSON output from that agent: + ```json + { + "business_analyst": {"business_model": "...", "target_audience": [...], "monetization": [...], "market_size": {...}, "risks": [...]}, + "market_researcher": {"competitors": [...], "market_gaps": [...], "positioning_recommendation": "..."}, + "legal_researcher": {"jurisdictions": [...], "required_licenses": [...], "compliance_risks": [...]}, + "tech_researcher": {"recommended_stack": [...], "apis": [...], "tech_constraints": [...], "cost_estimates": {...}}, + "ux_designer": {"personas": [...], "user_journey": [...], "key_screens": [...]}, + "marketer": {"positioning": "...", "acquisition_channels": [...], "seo_keywords": [...]} + } + ``` + Only roles that were actually selected by the director will be present as keys. + +2. **`## Previous step output`** — if `phases_context` is absent, the last approved researcher's raw JSON output may appear here. Use it as a fallback. + +If neither source is available, produce the blueprint based on `brief.text` (project description) alone. + +### Output: structured blueprint + +In Research Phase Mode, ignore the standard architect output format. Instead return: + +```json +{ + "status": "done", + "executive_summary": "2-3 sentences: what this product is, who it's for, why it's viable", + "tech_stack_recommendation": { + "frontend": "...", + "backend": "...", + "database": "...", + "infrastructure": "...", + "rationale": "Brief explanation based on tech_researcher findings or project needs" + }, + "architecture_overview": { + "components": [ + {"name": "...", "role": "...", "tech": "..."} + ], + "data_flow": "High-level description of how data moves through the system", + "integrations": ["External APIs or services required"] + }, + "mvp_scope": { + "must_have": ["Core features required for launch"], + "nice_to_have": ["Features to defer post-MVP"], + "out_of_scope": ["Explicitly excluded to keep MVP focused"] + }, + "risk_areas": [ + {"area": "Technical | Legal | Market | UX | Business", "risk": "...", "mitigation": "..."} + ], + "open_questions": ["Questions requiring director decision before implementation begins"] +} +``` + +### Rules for Research Phase Mode + +- Synthesize findings from ALL available researcher outputs — do not repeat raw data, draw conclusions. +- `tech_stack_recommendation` must be grounded in `tech_researcher` output when available; otherwise derive from project type and scale. +- `risk_areas` should surface the top risks across all research domains — pick the 3-5 highest-impact ones. +- `mvp_scope.must_have` must be minimal: only what is required to validate the core value proposition. +- Do NOT read or modify any code files in this mode — produce the spec only. + +--- + ## Blocked Protocol If you cannot perform the task (no file access, ambiguous requirements, task outside your scope), return this JSON **instead of** the normal output: diff --git a/agents/runner.py b/agents/runner.py index e492d6a..c3d500f 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -804,6 +804,21 @@ def run_pipeline( error_message=exc_msg, ) models.update_task(conn, task_id, status="blocked", blocked_reason=exc_msg) + try: + from core.telegram import send_telegram_escalation + project = models.get_project(conn, project_id) + project_name = project["name"] if project else project_id + sent = send_telegram_escalation( + task_id=task_id, + project_name=project_name, + agent_role=role, + reason=exc_msg, + pipeline_step=str(i + 1), + ) + if sent: + models.mark_telegram_sent(conn, task_id) + except Exception: + pass # Telegram errors must never block pipeline return { "success": False, "error": exc_msg, @@ -911,6 +926,21 @@ def run_pipeline( blocked_agent_role=role, blocked_pipeline_step=str(i + 1), ) + try: + from core.telegram import send_telegram_escalation + project = models.get_project(conn, project_id) + project_name = project["name"] if project else project_id + sent = send_telegram_escalation( + task_id=task_id, + project_name=project_name, + agent_role=role, + reason=blocked_info["reason"], + pipeline_step=str(i + 1), + ) + if sent: + models.mark_telegram_sent(conn, task_id) + except Exception: + pass # Telegram errors must never block pipeline error_msg = f"Step {i+1}/{len(steps)} ({role}) blocked: {blocked_info['reason']}" return { "success": False, diff --git a/core/db.py b/core/db.py index 61e7afc..d14ce3c 100644 --- a/core/db.py +++ b/core/db.py @@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS tasks ( dangerously_skipped BOOLEAN DEFAULT 0, revise_comment TEXT, category TEXT DEFAULT NULL, + telegram_sent BOOLEAN DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -285,6 +286,10 @@ def _migrate(conn: sqlite3.Connection): conn.execute("ALTER TABLE tasks ADD COLUMN blocked_pipeline_step TEXT") conn.commit() + if "telegram_sent" not in task_cols: + conn.execute("ALTER TABLE tasks ADD COLUMN telegram_sent BOOLEAN DEFAULT 0") + conn.commit() + if "obsidian_vault_path" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT") conn.commit() diff --git a/core/models.py b/core/models.py index 4ff898f..3947ed3 100644 --- a/core/models.py +++ b/core/models.py @@ -252,6 +252,15 @@ def update_task(conn: sqlite3.Connection, id: str, **fields) -> dict: return get_task(conn, id) +def mark_telegram_sent(conn: sqlite3.Connection, task_id: str) -> None: + """Mark that a Telegram escalation was sent for this task.""" + conn.execute( + "UPDATE tasks SET telegram_sent = 1 WHERE id = ?", + (task_id,), + ) + conn.commit() + + # --------------------------------------------------------------------------- # Decisions # --------------------------------------------------------------------------- diff --git a/core/telegram.py b/core/telegram.py new file mode 100644 index 0000000..3857e42 --- /dev/null +++ b/core/telegram.py @@ -0,0 +1,102 @@ +""" +Kin — Telegram escalation notifications. + +Sends a message when a PM agent detects a blocked agent. +Bot token is read from /Volumes/secrets/env/projects.env [kin] section. +Chat ID is read from KIN_TG_CHAT_ID env var. +""" + +import configparser +import json +import logging +import os +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +_logger = logging.getLogger("kin.telegram") + +_SECRETS_PATH = Path("/Volumes/secrets/env/projects.env") +_TELEGRAM_API = "https://api.telegram.org/bot{token}/sendMessage" + + +def _load_kin_config() -> dict: + """Load [kin] section from projects.env. Returns dict with available keys.""" + if not _SECRETS_PATH.exists(): + _logger.warning("secrets not mounted: %s", _SECRETS_PATH) + return {} + parser = configparser.ConfigParser() + parser.read(str(_SECRETS_PATH)) + if "kin" not in parser: + _logger.warning("No [kin] section in projects.env") + return {} + return dict(parser["kin"]) + + +def send_telegram_escalation( + task_id: str, + project_name: str, + agent_role: str, + reason: str, + pipeline_step: str | None, +) -> bool: + """Send a Telegram escalation message for a blocked agent. + + Returns True if message was sent successfully, False otherwise. + Never raises — escalation errors must never block the pipeline. + """ + config = _load_kin_config() + bot_token = config.get("tg_bot") or os.environ.get("KIN_TG_BOT_TOKEN") + if not bot_token: + _logger.warning("Telegram bot token not configured; skipping escalation for %s", task_id) + return False + + chat_id = os.environ.get("KIN_TG_CHAT_ID") + if not chat_id: + _logger.warning("KIN_TG_CHAT_ID not set; skipping Telegram escalation for %s", task_id) + return False + + step_info = f" (шаг {pipeline_step})" if pipeline_step else "" + text = ( + f"🚨 *Эскалация* — агент заблокирован\n\n" + f"*Проект:* {_escape_md(project_name)}\n" + f"*Задача:* `{task_id}`\n" + f"*Агент:* `{agent_role}{step_info}`\n" + f"*Причина:*\n{_escape_md(reason or '—')}" + ) + + payload = json.dumps({ + "chat_id": chat_id, + "text": text, + "parse_mode": "Markdown", + }).encode("utf-8") + + url = _TELEGRAM_API.format(token=bot_token) + req = urllib.request.Request( + url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status == 200: + _logger.info("Telegram escalation sent for task %s", task_id) + return True + _logger.warning("Telegram API returned status %d for task %s", resp.status, task_id) + return False + except urllib.error.URLError as exc: + _logger.warning("Telegram send failed for task %s: %s", task_id, exc) + return False + except Exception as exc: + _logger.warning("Unexpected Telegram error for task %s: %s", task_id, exc) + return False + + +def _escape_md(text: str) -> str: + """Escape Markdown special characters for Telegram MarkdownV1.""" + # MarkdownV1 is lenient — only escape backtick/asterisk/underscore in free text + for ch in ("*", "_", "`"): + text = text.replace(ch, f"\\{ch}") + return text diff --git a/web/api.py b/web/api.py index 10fb519..9a077a2 100644 --- a/web/api.py +++ b/web/api.py @@ -16,7 +16,7 @@ from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, FileResponse from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel +from pydantic import BaseModel, model_validator from core.db import init_db from core import models @@ -180,6 +180,12 @@ class ProjectCreate(BaseModel): ssh_key_path: str | None = None ssh_proxy_jump: str | None = None + @model_validator(mode="after") + def validate_operations_ssh_host(self) -> "ProjectCreate": + if self.project_type == "operations" and not self.ssh_host: + raise ValueError("ssh_host is required for operations projects") + return self + class ProjectPatch(BaseModel): execution_mode: str | None = None @@ -365,6 +371,9 @@ def approve_phase(phase_id: int, body: PhaseApprove | None = None): except ValueError as e: conn.close() raise HTTPException(400, str(e)) + # Mark the phase's task as done for consistency + if phase.get("task_id"): + models.update_task(conn, phase["task_id"], status="done") conn.close() return result @@ -644,6 +653,21 @@ def approve_task(task_id: str, body: TaskApprove | None = None): event="task_done", task_modules=task_modules) except Exception: pass + + # Advance phase state machine if this task belongs to an active phase + phase_result = None + phase_row = conn.execute( + "SELECT * FROM project_phases WHERE task_id = ?", (task_id,) + ).fetchone() + if phase_row: + phase = dict(phase_row) + if phase.get("status") == "active": + from core.phases import approve_phase as _approve_phase + try: + phase_result = _approve_phase(conn, phase["id"]) + except ValueError: + pass + decision = None if body and body.decision_title: decision = models.add_decision( @@ -664,6 +688,7 @@ def approve_task(task_id: str, body: TaskApprove | None = None): "followup_tasks": followup_tasks, "needs_decision": len(pending_actions) > 0, "pending_actions": pending_actions, + "phase": phase_result, } @@ -954,9 +979,8 @@ def get_notifications(project_id: str | None = None): """Return tasks with status='blocked' as escalation notifications. Each item includes task details, the agent role that blocked it, - the reason, and the pipeline step. Intended for GUI polling (5s interval). - - TODO: Telegram — send notification on new escalation (telegram_sent: false placeholder). + the reason, the pipeline step, and whether a Telegram alert was sent. + Intended for GUI polling (5s interval). """ conn = get_conn() query = "SELECT * FROM tasks WHERE status = 'blocked'" @@ -979,8 +1003,7 @@ def get_notifications(project_id: str | None = None): "reason": t.get("blocked_reason"), "pipeline_step": t.get("blocked_pipeline_step"), "blocked_at": t.get("blocked_at") or t.get("updated_at"), - # TODO: Telegram — set to True once notification is sent via Telegram bot - "telegram_sent": False, + "telegram_sent": bool(t.get("telegram_sent")), }) return notifications diff --git a/web/frontend/src/views/Dashboard.vue b/web/frontend/src/views/Dashboard.vue index ae0fff3..4a15de9 100644 --- a/web/frontend/src/views/Dashboard.vue +++ b/web/frontend/src/views/Dashboard.vue @@ -269,8 +269,16 @@ async function createNewProject() { - +
+ + Алиас из ~/.ssh/config на сервере Kin +
+