kin: KIN-ARCH-004 Добавить подсказку в форму о требовании ~/.ssh/config для ProxyJump
This commit is contained in:
parent
4188384f1b
commit
af554e15fa
7 changed files with 262 additions and 8 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
102
core/telegram.py
Normal 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
|
||||||
35
web/api.py
35
web/api.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue