kin: KIN-ARCH-004 Добавить подсказку в форму о требовании ~/.ssh/config для ProxyJump

This commit is contained in:
Gros Frumos 2026-03-16 09:43:26 +02:00
parent 4188384f1b
commit af554e15fa
7 changed files with 262 additions and 8 deletions

View file

@ -66,6 +66,83 @@ Valid values for `status`: `"done"`, `"blocked"`.
If status is "blocked", include `"blocked_reason": "..."`. 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 ## 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: If you cannot perform the task (no file access, ambiguous requirements, task outside your scope), return this JSON **instead of** the normal output:

View file

@ -804,6 +804,21 @@ def run_pipeline(
error_message=exc_msg, error_message=exc_msg,
) )
models.update_task(conn, task_id, status="blocked", blocked_reason=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 { return {
"success": False, "success": False,
"error": exc_msg, "error": exc_msg,
@ -911,6 +926,21 @@ def run_pipeline(
blocked_agent_role=role, blocked_agent_role=role,
blocked_pipeline_step=str(i + 1), 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']}" error_msg = f"Step {i+1}/{len(steps)} ({role}) blocked: {blocked_info['reason']}"
return { return {
"success": False, "success": False,

View file

@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS tasks (
dangerously_skipped BOOLEAN DEFAULT 0, dangerously_skipped BOOLEAN DEFAULT 0,
revise_comment TEXT, revise_comment TEXT,
category TEXT DEFAULT NULL, category TEXT DEFAULT NULL,
telegram_sent BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_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.execute("ALTER TABLE tasks ADD COLUMN blocked_pipeline_step TEXT")
conn.commit() 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: if "obsidian_vault_path" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT") conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT")
conn.commit() conn.commit()

View file

@ -252,6 +252,15 @@ def update_task(conn: sqlite3.Connection, id: str, **fields) -> dict:
return get_task(conn, id) 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 # Decisions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

102
core/telegram.py Normal file
View file

@ -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

View file

@ -16,7 +16,7 @@ from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel, model_validator
from core.db import init_db from core.db import init_db
from core import models from core import models
@ -180,6 +180,12 @@ class ProjectCreate(BaseModel):
ssh_key_path: str | None = None ssh_key_path: str | None = None
ssh_proxy_jump: 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): class ProjectPatch(BaseModel):
execution_mode: str | None = None execution_mode: str | None = None
@ -365,6 +371,9 @@ def approve_phase(phase_id: int, body: PhaseApprove | None = None):
except ValueError as e: except ValueError as e:
conn.close() conn.close()
raise HTTPException(400, str(e)) 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() conn.close()
return result return result
@ -644,6 +653,21 @@ def approve_task(task_id: str, body: TaskApprove | None = None):
event="task_done", task_modules=task_modules) event="task_done", task_modules=task_modules)
except Exception: except Exception:
pass 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 decision = None
if body and body.decision_title: if body and body.decision_title:
decision = models.add_decision( decision = models.add_decision(
@ -664,6 +688,7 @@ def approve_task(task_id: str, body: TaskApprove | None = None):
"followup_tasks": followup_tasks, "followup_tasks": followup_tasks,
"needs_decision": len(pending_actions) > 0, "needs_decision": len(pending_actions) > 0,
"pending_actions": pending_actions, "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. """Return tasks with status='blocked' as escalation notifications.
Each item includes task details, the agent role that blocked it, Each item includes task details, the agent role that blocked it,
the reason, and the pipeline step. Intended for GUI polling (5s interval). the reason, the pipeline step, and whether a Telegram alert was sent.
Intended for GUI polling (5s interval).
TODO: Telegram send notification on new escalation (telegram_sent: false placeholder).
""" """
conn = get_conn() conn = get_conn()
query = "SELECT * FROM tasks WHERE status = 'blocked'" query = "SELECT * FROM tasks WHERE status = 'blocked'"
@ -979,8 +1003,7 @@ def get_notifications(project_id: str | None = None):
"reason": t.get("blocked_reason"), "reason": t.get("blocked_reason"),
"pipeline_step": t.get("blocked_pipeline_step"), "pipeline_step": t.get("blocked_pipeline_step"),
"blocked_at": t.get("blocked_at") or t.get("updated_at"), "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": bool(t.get("telegram_sent")),
"telegram_sent": False,
}) })
return notifications return notifications

View file

@ -269,8 +269,16 @@ async function createNewProject() {
<input v-model="form.ssh_key_path" placeholder="Key path (e.g. ~/.ssh/id_rsa)" <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" /> class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
</div> </div>
<div>
<input v-model="form.ssh_proxy_jump" placeholder="ProxyJump (optional, e.g. jumpt)" <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" /> class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<p class="mt-1 flex items-center gap-1 text-xs text-gray-500">
<svg class="w-3 h-3 flex-shrink-0 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
Алиас из ~/.ssh/config на сервере Kin
</p>
</div>
</template> </template>
<input v-model="form.tech_stack" placeholder="Tech stack (comma-separated)" <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" /> class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />