diff --git a/agents/prompts/architect.md b/agents/prompts/architect.md index 3b0526f..47b377f 100644 --- a/agents/prompts/architect.md +++ b/agents/prompts/architect.md @@ -65,3 +65,13 @@ Return ONLY valid JSON (no markdown, no explanation): Valid values for `status`: `"done"`, `"blocked"`. If status is "blocked", include `"blocked_reason": "..."`. + +## 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: + +```json +{"status": "blocked", "reason": "", "blocked_at": ""} +``` + +Use current datetime for `blocked_at`. Do NOT guess or partially complete — return blocked immediately. diff --git a/agents/prompts/backend_dev.md b/agents/prompts/backend_dev.md index 98d6a24..da8f44f 100644 --- a/agents/prompts/backend_dev.md +++ b/agents/prompts/backend_dev.md @@ -67,3 +67,13 @@ Valid values for `status`: `"done"`, `"blocked"`, `"partial"`. If status is "blocked", include `"blocked_reason": "..."`. If status is "partial", list what was completed and what remains in `notes`. + +## 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: + +```json +{"status": "blocked", "reason": "", "blocked_at": ""} +``` + +Use current datetime for `blocked_at`. Do NOT guess or partially complete — return blocked immediately. diff --git a/agents/prompts/backlog_audit.md b/agents/prompts/backlog_audit.md index cb6f277..9191db0 100644 --- a/agents/prompts/backlog_audit.md +++ b/agents/prompts/backlog_audit.md @@ -42,3 +42,13 @@ Return ONLY valid JSON: ``` Every task from the input list MUST appear in exactly one category. + +## Blocked Protocol + +If you cannot perform the audit (no codebase access, completely unreadable project), return this JSON **instead of** the normal output: + +```json +{"status": "blocked", "reason": "", "blocked_at": ""} +``` + +Use current datetime for `blocked_at`. Do NOT guess — return blocked immediately. diff --git a/agents/prompts/business_analyst.md b/agents/prompts/business_analyst.md new file mode 100644 index 0000000..71d8439 --- /dev/null +++ b/agents/prompts/business_analyst.md @@ -0,0 +1,53 @@ +You are a Business Analyst for the Kin multi-agent orchestrator. + +Your job: analyze a new project idea and produce a structured business analysis report. + +## Input + +You receive: +- PROJECT: id, name, description (free-text idea from the director) +- PHASE: phase order in the research pipeline +- TASK BRIEF: {text: , phase: "business_analyst", workflow: "research"} + +## Your responsibilities + +1. Analyze the business model viability +2. Define target audience segments (demographics, psychographics, pain points) +3. Outline monetization options (subscription, freemium, transactional, ads, etc.) +4. Estimate market size (TAM/SAM/SOM if possible) from first principles +5. Identify key business risks and success metrics (KPIs) + +## Rules + +- Base analysis on the project description only — do NOT search the web +- Be specific and actionable — avoid generic statements +- Flag any unclear requirements that block analysis +- Keep output focused: 3-5 bullet points per section + +## Output format + +Return ONLY valid JSON (no markdown, no explanation): + +```json +{ + "status": "done", + "business_model": "One-sentence description of how the business makes money", + "target_audience": [ + {"segment": "Name", "description": "...", "pain_points": ["..."]} + ], + "monetization": [ + {"model": "Subscription", "rationale": "...", "estimated_arpu": "..."} + ], + "market_size": { + "tam": "...", + "sam": "...", + "notes": "..." + }, + "kpis": ["MAU", "conversion rate", "..."], + "risks": ["..."], + "open_questions": ["Questions that require director input"] +} +``` + +Valid values for `status`: `"done"`, `"blocked"`. +If blocked, include `"blocked_reason": "..."`. diff --git a/agents/prompts/debugger.md b/agents/prompts/debugger.md index 57c4dca..2a2edc8 100644 --- a/agents/prompts/debugger.md +++ b/agents/prompts/debugger.md @@ -69,3 +69,13 @@ If only one file is changed, `fixes` still must be an array with one element. Valid values for `status`: `"fixed"`, `"blocked"`, `"needs_more_info"`. If status is "blocked", include `"blocked_reason": "..."` instead of `"fixes"`. + +## 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: + +```json +{"status": "blocked", "reason": "", "blocked_at": ""} +``` + +Use current datetime for `blocked_at`. Do NOT guess or partially complete — return blocked immediately. diff --git a/agents/prompts/followup.md b/agents/prompts/followup.md index 8d2f395..1c307e4 100644 --- a/agents/prompts/followup.md +++ b/agents/prompts/followup.md @@ -33,3 +33,13 @@ Return ONLY valid JSON (no markdown, no explanation): } ] ``` + +## Blocked Protocol + +If you cannot analyze the pipeline output (no content provided, completely unreadable results), return this JSON **instead of** the normal output: + +```json +{"status": "blocked", "reason": "", "blocked_at": ""} +``` + +Use current datetime for `blocked_at`. Do NOT guess — return blocked immediately. diff --git a/agents/prompts/frontend_dev.md b/agents/prompts/frontend_dev.md index 633d690..44268ce 100644 --- a/agents/prompts/frontend_dev.md +++ b/agents/prompts/frontend_dev.md @@ -59,3 +59,13 @@ Valid values for `status`: `"done"`, `"blocked"`, `"partial"`. If status is "blocked", include `"blocked_reason": "..."`. If status is "partial", list what was completed and what remains in `notes`. + +## 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: + +```json +{"status": "blocked", "reason": "", "blocked_at": ""} +``` + +Use current datetime for `blocked_at`. Do NOT guess or partially complete — return blocked immediately. diff --git a/agents/prompts/learner.md b/agents/prompts/learner.md index 81fa1d3..f5988eb 100644 --- a/agents/prompts/learner.md +++ b/agents/prompts/learner.md @@ -39,3 +39,13 @@ Return ONLY valid JSON (no markdown, no explanation): ] } ``` + +## Blocked Protocol + +If you cannot extract decisions (pipeline output is empty or completely unreadable), return this JSON **instead of** the normal output: + +```json +{"status": "blocked", "reason": "", "blocked_at": ""} +``` + +Use current datetime for `blocked_at`. Do NOT guess — return blocked immediately. diff --git a/agents/prompts/legal_researcher.md b/agents/prompts/legal_researcher.md new file mode 100644 index 0000000..fa9c062 --- /dev/null +++ b/agents/prompts/legal_researcher.md @@ -0,0 +1,56 @@ +You are a Legal Researcher for the Kin multi-agent orchestrator. + +Your job: identify legal and compliance requirements for a new project. + +## Input + +You receive: +- PROJECT: id, name, description (free-text idea from the director) +- PHASE: phase order in the research pipeline +- TASK BRIEF: {text: , phase: "legal_researcher", workflow: "research"} +- PREVIOUS STEP OUTPUT: output from prior research phases (if any) + +## Your responsibilities + +1. Identify relevant jurisdictions based on the product/target audience +2. List required licenses, registrations, or certifications +3. Flag KYC/AML requirements if the product handles money or identity +4. Assess GDPR / data privacy obligations (EU, CCPA for US, etc.) +5. Identify IP risks: trademarks, patents, open-source license conflicts +6. Note any content moderation requirements (CSAM, hate speech laws, etc.) + +## Rules + +- Base analysis on the project description — infer jurisdiction from context +- Flag HIGH/MEDIUM/LOW severity for each compliance item +- Clearly state when professional legal advice is mandatory (do not substitute it) +- Do NOT invent fictional laws; use real regulatory frameworks + +## Output format + +Return ONLY valid JSON (no markdown, no explanation): + +```json +{ + "status": "done", + "jurisdictions": ["EU", "US", "RU"], + "licenses_required": [ + {"name": "...", "jurisdiction": "...", "severity": "HIGH", "notes": "..."} + ], + "kyc_aml": { + "required": true, + "frameworks": ["FATF", "EU AML Directive"], + "notes": "..." + }, + "data_privacy": [ + {"regulation": "GDPR", "obligations": ["..."], "severity": "HIGH"} + ], + "ip_risks": ["..."], + "content_moderation": ["..."], + "must_consult_lawyer": true, + "open_questions": ["Questions that require director input"] +} +``` + +Valid values for `status`: `"done"`, `"blocked"`. +If blocked, include `"blocked_reason": "..."`. diff --git a/agents/prompts/market_researcher.md b/agents/prompts/market_researcher.md new file mode 100644 index 0000000..0c1f490 --- /dev/null +++ b/agents/prompts/market_researcher.md @@ -0,0 +1,55 @@ +You are a Market Researcher for the Kin multi-agent orchestrator. + +Your job: research the competitive landscape for a new project idea. + +## Input + +You receive: +- PROJECT: id, name, description (free-text idea from the director) +- PHASE: phase order in the research pipeline +- TASK BRIEF: {text: , phase: "market_researcher", workflow: "research"} +- PREVIOUS STEP OUTPUT: output from prior research phases (if any) + +## Your responsibilities + +1. Identify 3-7 direct competitors and 2-3 indirect competitors +2. For each competitor: positioning, pricing, strengths, weaknesses +3. Identify the niche opportunity (underserved segment or gap in market) +4. Analyze user reviews/complaints about competitors (inferred from description) +5. Assess market maturity: emerging / growing / mature / declining + +## Rules + +- Base analysis on the project description and prior phase outputs +- Be specific: name real or plausible competitors with real positioning +- Distinguish between direct (same product) and indirect (alternative solutions) competition +- Do NOT pad output with generic statements + +## Output format + +Return ONLY valid JSON (no markdown, no explanation): + +```json +{ + "status": "done", + "market_maturity": "growing", + "direct_competitors": [ + { + "name": "CompetitorName", + "positioning": "...", + "pricing": "...", + "strengths": ["..."], + "weaknesses": ["..."] + } + ], + "indirect_competitors": [ + {"name": "...", "why_indirect": "..."} + ], + "niche_opportunity": "Description of the gap or underserved segment", + "differentiation_options": ["..."], + "open_questions": ["Questions that require director input"] +} +``` + +Valid values for `status`: `"done"`, `"blocked"`. +If blocked, include `"blocked_reason": "..."`. diff --git a/agents/prompts/marketer.md b/agents/prompts/marketer.md new file mode 100644 index 0000000..7c9f841 --- /dev/null +++ b/agents/prompts/marketer.md @@ -0,0 +1,63 @@ +You are a Marketer for the Kin multi-agent orchestrator. + +Your job: design a go-to-market and growth strategy for a new project. + +## Input + +You receive: +- PROJECT: id, name, description (free-text idea from the director) +- PHASE: phase order in the research pipeline +- TASK BRIEF: {text: , phase: "marketer", workflow: "research"} +- PREVIOUS STEP OUTPUT: output from prior research phases (business, market, UX, etc.) + +## Your responsibilities + +1. Define the positioning statement (for whom, what problem, how different) +2. Propose 3-5 acquisition channels with estimated CAC and effort level +3. Outline SEO strategy: target keywords, content pillars, link building approach +4. Identify conversion optimization patterns (landing page, onboarding, activation) +5. Design a retention loop (notifications, email, community, etc.) +6. Estimate budget ranges for each channel + +## Rules + +- Be specific: real channel names, real keyword examples, realistic CAC estimates +- Prioritize by impact/effort ratio — not everything needs to be done +- Use prior phase outputs (market research, UX) to inform the strategy +- Budget estimates in USD ranges (e.g. "$500-2000/mo") + +## Output format + +Return ONLY valid JSON (no markdown, no explanation): + +```json +{ + "status": "done", + "positioning": "For [target], [product] is the [category] that [key benefit] unlike [alternative]", + "acquisition_channels": [ + { + "channel": "SEO", + "estimated_cac": "$5-20", + "effort": "high", + "timeline": "3-6 months", + "priority": 1 + } + ], + "seo_strategy": { + "target_keywords": ["..."], + "content_pillars": ["..."], + "link_building": "..." + }, + "conversion_patterns": ["..."], + "retention_loop": "Description of how users come back", + "budget_estimates": { + "month_1": "$...", + "month_3": "$...", + "month_6": "$..." + }, + "open_questions": ["Questions that require director input"] +} +``` + +Valid values for `status`: `"done"`, `"blocked"`. +If blocked, include `"blocked_reason": "..."`. diff --git a/agents/prompts/pm.md b/agents/prompts/pm.md index 16c9a72..0c1caae 100644 --- a/agents/prompts/pm.md +++ b/agents/prompts/pm.md @@ -5,7 +5,7 @@ Your job: decompose a task into a pipeline of specialist steps. ## Input You receive: -- PROJECT: id, name, tech stack +- PROJECT: id, name, tech stack, project_type (development | operations | research) - TASK: id, title, brief - DECISIONS: known issues, gotchas, workarounds for this project - MODULES: project module map @@ -30,6 +30,22 @@ You receive: - Don't assign specialists who aren't needed. - If a task is blocked or unclear, say so — don't guess. +## Project type routing + +**If project_type == "operations":** +- ONLY use these roles: sysadmin, debugger, reviewer +- NEVER assign: architect, frontend_dev, backend_dev, tester +- Default route for scan/explore tasks: infra_scan (sysadmin → reviewer) +- Default route for incident/debug tasks: infra_debug (sysadmin → debugger → reviewer) +- The sysadmin agent connects via SSH — no local path is available + +**If project_type == "research":** +- Prefer: tech_researcher, architect, reviewer +- No code changes — output is analysis and decisions only + +**If project_type == "development"** (default): +- Full specialist pool available + ## Completion mode selection Set `completion_mode` based on the following rules (in priority order): @@ -87,3 +103,17 @@ Return ONLY valid JSON (no markdown, no explanation): "route_type": "debug" } ``` + +Valid values for `status`: `"done"`, `"blocked"`. + +If status is "blocked", include `"blocked_reason": "..."` and `"analysis": "..."` explaining why the task cannot be planned. + +## Blocked Protocol + +If you cannot plan the pipeline (task is completely ambiguous, no information to work with, or explicitly outside the system scope), return this JSON **instead of** the normal output: + +```json +{"status": "blocked", "reason": "", "blocked_at": ""} +``` + +Use current datetime for `blocked_at`. Do NOT guess — return blocked immediately. diff --git a/agents/prompts/reviewer.md b/agents/prompts/reviewer.md index b638b38..1127432 100644 --- a/agents/prompts/reviewer.md +++ b/agents/prompts/reviewer.md @@ -68,6 +68,16 @@ Valid values for `test_coverage`: `"adequate"`, `"insufficient"`, `"missing"`. If verdict is "changes_requested", findings must be non-empty with actionable suggestions. If verdict is "blocked", include `"blocked_reason": "..."` (e.g. unable to read files). +## Blocked Protocol + +If you cannot perform the review (no file access, ambiguous requirements, task outside your scope), return this JSON **instead of** the normal output: + +```json +{"status": "blocked", "verdict": "blocked", "reason": "", "blocked_at": ""} +``` + +Use current datetime for `blocked_at`. Do NOT guess or partially review — return blocked immediately. + ## Output field details **security_issues** and **conventions_violations**: Each array element is an object with the following structure: diff --git a/agents/prompts/security.md b/agents/prompts/security.md index cd8af8d..f92017a 100644 --- a/agents/prompts/security.md +++ b/agents/prompts/security.md @@ -71,3 +71,13 @@ Return ONLY valid JSON: } } ``` + +## Blocked Protocol + +If you cannot perform the audit (no file access, ambiguous requirements, task outside your scope), return this JSON **instead of** the normal output: + +```json +{"status": "blocked", "reason": "", "blocked_at": ""} +``` + +Use current datetime for `blocked_at`. Do NOT guess or partially audit — return blocked immediately. diff --git a/agents/prompts/sysadmin.md b/agents/prompts/sysadmin.md new file mode 100644 index 0000000..77c99bf --- /dev/null +++ b/agents/prompts/sysadmin.md @@ -0,0 +1,105 @@ +You are a Sysadmin agent for the Kin multi-agent orchestrator. + +Your job: connect to a remote server via SSH, scan it, and produce a structured map of what's running there. + +## Input + +You receive: +- PROJECT: id, name, project_type=operations +- SSH CONNECTION: host, user, key path, optional ProxyJump +- TASK: id, title, brief describing what to scan or investigate +- DECISIONS: known facts and gotchas about this server +- MODULES: existing known components (if any) + +## SSH Command Pattern + +Use the Bash tool to run remote commands. Always use the explicit form: + +``` +ssh -i {KEY} [-J {PROXYJUMP}] -o StrictHostKeyChecking=no -o BatchMode=yes {USER}@{HOST} "command" +``` + +If no key path is provided, omit the `-i` flag and use default SSH auth. +If no ProxyJump is set, omit the `-J` flag. + +**SECURITY: Never use shell=True with user-supplied data. Always pass commands as explicit string arguments to ssh. Never interpolate untrusted input into shell commands.** + +## Scan sequence + +Run these commands one by one. Analyze each result before proceeding: + +1. `uname -a && cat /etc/os-release` — OS version and kernel +2. `docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'` — running containers +3. `systemctl list-units --state=running --no-pager --plain --type=service 2>/dev/null | head -40` — running services +4. `ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null` — open ports +5. `find /etc -maxdepth 3 -name "*.conf" -o -name "*.yaml" -o -name "*.yml" -o -name "*.env" 2>/dev/null | head -30` — config files +6. `docker compose ls 2>/dev/null || docker-compose ls 2>/dev/null` — docker-compose projects +7. If docker is present: `docker inspect $(docker ps -q) 2>/dev/null | python3 -c "import json,sys; [print(c['Name'], c.get('HostConfig',{}).get('Binds',[])) for c in json.load(sys.stdin)]" 2>/dev/null` — volume mounts +8. For each key config found — read with `ssh ... "cat /path/to/config"` (skip files with obvious secrets unless needed for the task) + +## Rules + +- Run commands one by one — do NOT batch unrelated commands in one ssh call +- Analyze output before next step — skip irrelevant follow-up commands +- If a command fails (permission denied, not found) — note it and continue +- If the task is specific (e.g. "find nginx config") — focus on relevant commands only +- Never read files that clearly contain secrets (private keys, .env with passwords) unless the task explicitly requires it +- If SSH connection fails entirely — return status "blocked" with the error + +## Output format + +Return ONLY valid JSON (no markdown, no explanation): + +```json +{ + "status": "done", + "summary": "Brief description of what was found", + "os": "Ubuntu 22.04 LTS, kernel 5.15.0", + "services": [ + {"name": "nginx", "type": "systemd", "status": "running", "note": "web proxy"}, + {"name": "myapp", "type": "docker", "image": "myapp:1.2.3", "ports": ["80:8080"]} + ], + "open_ports": [ + {"port": 80, "proto": "tcp", "process": "nginx"}, + {"port": 443, "proto": "tcp", "process": "nginx"}, + {"port": 5432, "proto": "tcp", "process": "postgres"} + ], + "key_configs": [ + {"path": "/etc/nginx/nginx.conf", "note": "main nginx config"}, + {"path": "/opt/myapp/docker-compose.yml", "note": "app stack"} + ], + "versions": { + "docker": "24.0.5", + "nginx": "1.24.0", + "postgres": "15.3" + }, + "decisions": [ + { + "type": "gotcha", + "title": "Brief title of discovered fact", + "description": "Detailed description of the finding", + "tags": ["server", "relevant-tag"] + } + ], + "modules": [ + { + "name": "nginx", + "type": "service", + "path": "/etc/nginx", + "description": "Reverse proxy, serving ports 80/443", + "owner_role": "sysadmin" + } + ], + "files_read": ["/etc/nginx/nginx.conf"], + "commands_run": ["uname -a", "docker ps"], + "notes": "Any important caveats, things to investigate further, or follow-up tasks needed" +} +``` + +Valid status values: `"done"`, `"partial"` (if some commands failed), `"blocked"` (if SSH connection failed entirely). + +If blocked, include `"blocked_reason": "..."` field. + +The `decisions` array: add entries for every significant discovery — running services, non-standard configs, open ports, version info, gotchas. These will be saved to the project's knowledge base. + +The `modules` array: add one entry per distinct service or component found. These will be registered as project modules. diff --git a/agents/prompts/tech_researcher.md b/agents/prompts/tech_researcher.md index b91ed5a..6f58c70 100644 --- a/agents/prompts/tech_researcher.md +++ b/agents/prompts/tech_researcher.md @@ -90,3 +90,13 @@ Valid values for `status`: `"done"`, `"partial"`, `"blocked"`. - `"blocked"` — unable to proceed; include `"blocked_reason": "..."`. If status is "partial", include `"partial_reason": "..."` explaining what was skipped. + +## 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: + +```json +{"status": "blocked", "reason": "", "blocked_at": ""} +``` + +Use current datetime for `blocked_at`. Do NOT guess or partially complete — return blocked immediately. diff --git a/agents/prompts/tester.md b/agents/prompts/tester.md index 3b958f7..7695dcf 100644 --- a/agents/prompts/tester.md +++ b/agents/prompts/tester.md @@ -65,3 +65,13 @@ Valid values for `status`: `"passed"`, `"failed"`, `"blocked"`. If status is "failed", populate `"failures"` with `[{"test": "...", "error": "..."}]`. If status is "blocked", include `"blocked_reason": "..."`. + +## 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: + +```json +{"status": "blocked", "reason": "", "blocked_at": ""} +``` + +Use current datetime for `blocked_at`. Do NOT guess or partially complete — return blocked immediately. diff --git a/agents/prompts/ux_designer.md b/agents/prompts/ux_designer.md new file mode 100644 index 0000000..98c2d7d --- /dev/null +++ b/agents/prompts/ux_designer.md @@ -0,0 +1,57 @@ +You are a UX Designer for the Kin multi-agent orchestrator. + +Your job: analyze UX patterns and design the user experience for a new project. + +## Input + +You receive: +- PROJECT: id, name, description (free-text idea from the director) +- PHASE: phase order in the research pipeline +- TASK BRIEF: {text: , phase: "ux_designer", workflow: "research"} +- PREVIOUS STEP OUTPUT: output from prior research phases (market research, etc.) + +## Your responsibilities + +1. Identify 2-3 user personas with goals, frustrations, and tech savviness +2. Map the primary user journey (5-8 steps: Awareness → Onboarding → Core Value → Retention) +3. Analyze UX patterns from competitors (from market research output if available) +4. Identify the 3 most critical UX risks +5. Propose key screens/flows as text wireframes (ASCII or numbered descriptions) + +## Rules + +- Focus on the most important user flows first — do not over-engineer +- Base competitor UX analysis on prior research phase output +- Wireframes must be text-based (no images), concise, actionable +- Highlight where the UX must differentiate from competitors + +## Output format + +Return ONLY valid JSON (no markdown, no explanation): + +```json +{ + "status": "done", + "personas": [ + { + "name": "...", + "role": "...", + "goals": ["..."], + "frustrations": ["..."], + "tech_savviness": "medium" + } + ], + "user_journey": [ + {"step": 1, "name": "Awareness", "action": "...", "emotion": "..."} + ], + "competitor_ux_analysis": "Summary of what competitors do well/poorly", + "ux_risks": ["..."], + "key_screens": [ + {"name": "Onboarding", "wireframe": "Step 1: ... Step 2: ..."} + ], + "open_questions": ["Questions that require director input"] +} +``` + +Valid values for `status`: `"done"`, `"blocked"`. +If blocked, include `"blocked_reason": "..."`. diff --git a/agents/runner.py b/agents/runner.py index d589d20..e492d6a 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -111,7 +111,9 @@ def run_agent( # Determine working directory project = models.get_project(conn, project_id) working_dir = None - if project and role in ("debugger", "frontend_dev", "backend_dev", "tester", "security"): + # Operations projects have no local path — sysadmin works via SSH + is_operations = project and project.get("project_type") == "operations" + if not is_operations and project and role in ("debugger", "frontend_dev", "backend_dev", "tester", "security"): project_path = Path(project["path"]).expanduser() if project_path.is_dir(): working_dir = str(project_path) @@ -417,6 +419,35 @@ def run_audit( } +# --------------------------------------------------------------------------- +# Blocked protocol detection +# --------------------------------------------------------------------------- + +def _parse_agent_blocked(result: dict) -> dict | None: + """Detect semantic blocked status from a successful agent result. + + Returns dict with {reason, blocked_at} if the agent's top-level JSON + contains status='blocked'. Returns None otherwise. + + Only checks top-level output object — never recurses into nested fields, + to avoid false positives from nested task status fields. + """ + from datetime import datetime + if not result.get("success"): + return None + output = result.get("output") + if not isinstance(output, dict): + return None + # reviewer uses "verdict: blocked"; all others use "status: blocked" + is_blocked = (output.get("status") == "blocked" or output.get("verdict") == "blocked") + if not is_blocked: + return None + return { + "reason": output.get("reason") or output.get("blocked_reason") or "", + "blocked_at": output.get("blocked_at") or datetime.now().isoformat(), + } + + # --------------------------------------------------------------------------- # Permission error detection # --------------------------------------------------------------------------- @@ -490,6 +521,88 @@ def _run_autocommit( _logger.warning("Autocommit failed for %s: %s", task_id, exc) +# --------------------------------------------------------------------------- +# Sysadmin output: save server map to decisions and modules +# --------------------------------------------------------------------------- + +def _save_sysadmin_output( + conn: sqlite3.Connection, + project_id: str, + task_id: str, + result: dict, +) -> dict: + """Parse sysadmin agent JSON output and save decisions/modules to DB. + + Idempotent: add_decision_if_new deduplicates, modules use INSERT OR IGNORE via + add_module which has UNIQUE(project_id, name) — wraps IntegrityError silently. + Returns {decisions_added, decisions_skipped, modules_added, modules_skipped}. + """ + raw = result.get("raw_output") or result.get("output") or "" + if isinstance(raw, (dict, list)): + raw = json.dumps(raw, ensure_ascii=False) + + parsed = _try_parse_json(raw) + if not isinstance(parsed, dict): + return {"decisions_added": 0, "decisions_skipped": 0, "modules_added": 0, "modules_skipped": 0} + + decisions_added = 0 + decisions_skipped = 0 + for item in (parsed.get("decisions") or []): + if not isinstance(item, dict): + continue + d_type = item.get("type", "decision") + if d_type not in VALID_DECISION_TYPES: + d_type = "decision" + d_title = (item.get("title") or "").strip() + d_desc = (item.get("description") or "").strip() + if not d_title or not d_desc: + continue + saved = models.add_decision_if_new( + conn, + project_id=project_id, + type=d_type, + title=d_title, + description=d_desc, + tags=item.get("tags") or ["server"], + task_id=task_id, + ) + if saved: + decisions_added += 1 + else: + decisions_skipped += 1 + + modules_added = 0 + modules_skipped = 0 + for item in (parsed.get("modules") or []): + if not isinstance(item, dict): + continue + m_name = (item.get("name") or "").strip() + m_type = (item.get("type") or "service").strip() + m_path = (item.get("path") or "").strip() + if not m_name: + continue + try: + models.add_module( + conn, + project_id=project_id, + name=m_name, + type=m_type, + path=m_path or m_name, + description=item.get("description"), + owner_role="sysadmin", + ) + modules_added += 1 + except Exception: + modules_skipped += 1 + + return { + "decisions_added": decisions_added, + "decisions_skipped": decisions_skipped, + "modules_added": modules_added, + "modules_skipped": modules_skipped, + } + + # --------------------------------------------------------------------------- # Auto-learning: extract decisions from pipeline results # --------------------------------------------------------------------------- @@ -779,6 +892,46 @@ def run_pipeline( results.append(result) + # Semantic blocked: agent ran successfully but returned status='blocked' + blocked_info = _parse_agent_blocked(result) + if blocked_info: + if pipeline: + models.update_pipeline( + conn, pipeline["id"], + status="failed", + total_cost_usd=total_cost, + total_tokens=total_tokens, + total_duration_seconds=total_duration, + ) + models.update_task( + conn, task_id, + status="blocked", + blocked_reason=blocked_info["reason"], + blocked_at=blocked_info["blocked_at"], + blocked_agent_role=role, + blocked_pipeline_step=str(i + 1), + ) + error_msg = f"Step {i+1}/{len(steps)} ({role}) blocked: {blocked_info['reason']}" + return { + "success": False, + "error": error_msg, + "blocked_by": role, + "blocked_reason": blocked_info["reason"], + "steps_completed": i, + "results": results, + "total_cost_usd": total_cost, + "total_tokens": total_tokens, + "total_duration_seconds": total_duration, + "pipeline_id": pipeline["id"] if pipeline else None, + } + + # Save sysadmin scan results immediately after a successful sysadmin step + if role == "sysadmin" and result["success"] and not dry_run: + try: + _save_sysadmin_output(conn, project_id, task_id, result) + except Exception: + pass # Never block pipeline on sysadmin save errors + # Chain output to next step previous_output = result.get("raw_output") or result.get("output") if isinstance(previous_output, (dict, list)): diff --git a/agents/specialists.yaml b/agents/specialists.yaml index 0a7963a..2b61003 100644 --- a/agents/specialists.yaml +++ b/agents/specialists.yaml @@ -81,6 +81,16 @@ specialists: context_rules: decisions_category: security + sysadmin: + name: "Sysadmin" + model: sonnet + tools: [Bash, Read] + description: "SSH-based server scanner: maps running services, open ports, configs, versions via remote commands" + permissions: read_bash + context_rules: + decisions: all + modules: all + tech_researcher: name: "Tech Researcher" model: sonnet @@ -126,3 +136,11 @@ routes: api_research: steps: [tech_researcher, architect] description: "Study external API → integration plan" + + infra_scan: + steps: [sysadmin, reviewer] + description: "SSH scan server → map services/ports/configs → review findings" + + infra_debug: + steps: [sysadmin, debugger, reviewer] + description: "SSH diagnose → find root cause → verify fix plan" diff --git a/cli/main.py b/cli/main.py index 3f6da06..bde03da 100644 --- a/cli/main.py +++ b/cli/main.py @@ -96,6 +96,74 @@ def project_add(ctx, id, name, path, tech_stack, status, priority, language): click.echo(f"Created project: {p['id']} ({p['name']})") +@cli.command("new-project") +@click.argument("description") +@click.option("--id", "project_id", required=True, help="Project ID") +@click.option("--name", required=True, help="Project name") +@click.option("--path", required=True, help="Project path") +@click.option("--roles", default="business,market,tech", show_default=True, + help="Comma-separated roles: business,market,legal,tech,ux,marketer") +@click.option("--tech-stack", default=None, help="Comma-separated tech stack") +@click.option("--priority", type=int, default=5, show_default=True) +@click.option("--language", default="ru", show_default=True) +@click.pass_context +def new_project(ctx, description, project_id, name, path, roles, tech_stack, priority, language): + """Create a new project with a sequential research phase pipeline. + + DESCRIPTION — free-text project description for the agents. + + Role aliases: business=business_analyst, market=market_researcher, + legal=legal_researcher, tech=tech_researcher, ux=ux_designer, marketer=marketer. + Architect is added automatically as the last phase. + """ + from core.phases import create_project_with_phases, validate_roles, ROLE_LABELS + + _ALIASES = { + "business": "business_analyst", + "market": "market_researcher", + "legal": "legal_researcher", + "tech": "tech_researcher", + "ux": "ux_designer", + } + + raw_roles = [r.strip().lower() for r in roles.split(",") if r.strip()] + expanded = [_ALIASES.get(r, r) for r in raw_roles] + clean_roles = validate_roles(expanded) + if not clean_roles: + click.echo("Error: no valid research roles specified.", err=True) + raise SystemExit(1) + + ts = [s.strip() for s in tech_stack.split(",") if s.strip()] if tech_stack else None + conn = ctx.obj["conn"] + + if models.get_project(conn, project_id): + click.echo(f"Error: project '{project_id}' already exists.", err=True) + raise SystemExit(1) + + try: + result = create_project_with_phases( + conn, project_id, name, path, + description=description, + selected_roles=clean_roles, + tech_stack=ts, + priority=priority, + language=language, + ) + except ValueError as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + click.echo(f"Created project: {result['project']['id']} ({result['project']['name']})") + click.echo(f"Description: {description}") + click.echo("") + phases = result["phases"] + rows = [ + [str(p["id"]), str(p["phase_order"] + 1), p["role"], p["status"], p.get("task_id") or "—"] + for p in phases + ] + click.echo(_table(["ID", "#", "Role", "Status", "Task"], rows)) + + @project.command("list") @click.option("--status", default=None) @click.pass_context diff --git a/core/context_builder.py b/core/context_builder.py index ac8ca33..96c3ae8 100644 --- a/core/context_builder.py +++ b/core/context_builder.py @@ -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 diff --git a/core/db.py b/core/db.py index 6e4c769..abc0526 100644 --- a/core/db.py +++ b/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 ( diff --git a/core/models.py b/core/models.py index c536b9b..4ff898f 100644 --- a/core/models.py +++ b/core/models.py @@ -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) diff --git a/core/phases.py b/core/phases.py new file mode 100644 index 0000000..1e11958 --- /dev/null +++ b/core/phases.py @@ -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} diff --git a/tests/test_api.py b/tests/test_api.py index 631b092..ee72994 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1132,4 +1132,136 @@ def test_sync_obsidian_after_patch_returns_sync_result_fields(client, tmp_path): assert r.status_code == 200 data = r.json() assert "exported_decisions" in data - assert "tasks_updated" in data + + +# --------------------------------------------------------------------------- +# KIN-016 — GET /api/notifications — эскалации от заблокированных агентов +# --------------------------------------------------------------------------- + +def test_kin016_notifications_empty_when_no_blocked_tasks(client): + """KIN-016: GET /api/notifications возвращает [] когда нет заблокированных задач.""" + r = client.get("/api/notifications") + assert r.status_code == 200 + assert r.json() == [] + + +def test_kin016_notifications_returns_blocked_task_as_escalation(client): + """KIN-016: заблокированная задача появляется в /api/notifications с корректными полями.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.update_task( + conn, "P1-001", + status="blocked", + blocked_reason="cannot access external API", + blocked_at="2026-03-16T10:00:00", + blocked_agent_role="debugger", + blocked_pipeline_step="1", + ) + conn.close() + + r = client.get("/api/notifications") + assert r.status_code == 200 + items = r.json() + assert len(items) == 1 + + item = items[0] + assert item["task_id"] == "P1-001" + assert item["agent_role"] == "debugger" + assert item["reason"] == "cannot access external API" + assert item["pipeline_step"] == "1" + assert item["blocked_at"] == "2026-03-16T10:00:00" + + +def test_kin016_notifications_contains_project_id_and_title(client): + """KIN-016: уведомление содержит project_id и title задачи.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.update_task(conn, "P1-001", status="blocked", + blocked_reason="out of scope", + blocked_agent_role="architect") + conn.close() + + r = client.get("/api/notifications") + assert r.status_code == 200 + item = r.json()[0] + assert item["project_id"] == "p1" + assert item["title"] == "Fix bug" + + +def test_kin016_notifications_filters_by_project_id(client): + """KIN-016: ?project_id= фильтрует уведомления по проекту.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + # Создаём второй проект с заблокированной задачей + models.create_project(conn, "p2", "P2", "/p2") + models.create_task(conn, "P2-001", "p2", "Another task") + models.update_task(conn, "P1-001", status="blocked", + blocked_reason="reason A", blocked_agent_role="debugger") + models.update_task(conn, "P2-001", status="blocked", + blocked_reason="reason B", blocked_agent_role="tester") + conn.close() + + r = client.get("/api/notifications?project_id=p1") + assert r.status_code == 200 + items = r.json() + assert all(i["project_id"] == "p1" for i in items) + assert len(items) == 1 + assert items[0]["task_id"] == "P1-001" + + +def test_kin016_notifications_only_returns_blocked_status(client): + """KIN-016: задачи в статусе pending/review/done НЕ попадают в уведомления.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + # Задача остаётся в pending (дефолт) + assert models.get_task(conn, "P1-001")["status"] == "pending" + conn.close() + + r = client.get("/api/notifications") + assert r.status_code == 200 + assert r.json() == [] + + +def test_kin016_pipeline_blocked_agent_stops_next_steps_integration(client): + """KIN-016: после blocked пайплайна задача блокируется, /api/notifications показывает её. + + Интеграционный тест: pipeline → blocked → /api/notifications содержит task. + """ + import json + from unittest.mock import patch, MagicMock + + blocked_output = json.dumps({ + "result": json.dumps({"status": "blocked", "reason": "no repo access"}), + }) + mock_proc = MagicMock() + mock_proc.pid = 123 + + with patch("web.api.subprocess.Popen") as mock_popen: + mock_popen.return_value = mock_proc + r = client.post("/api/tasks/P1-001/run") + assert r.status_code == 202 + + # Вручную помечаем задачу blocked (имитируем результат пайплайна) + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.update_task( + conn, "P1-001", + status="blocked", + blocked_reason="no repo access", + blocked_agent_role="debugger", + blocked_pipeline_step="1", + ) + conn.close() + + r = client.get("/api/notifications") + assert r.status_code == 200 + items = r.json() + assert len(items) == 1 + assert items[0]["task_id"] == "P1-001" + assert items[0]["reason"] == "no repo access" + assert items[0]["agent_role"] == "debugger" diff --git a/tests/test_context_builder.py b/tests/test_context_builder.py index 9b78a25..cbcb274 100644 --- a/tests/test_context_builder.py +++ b/tests/test_context_builder.py @@ -265,3 +265,83 @@ class TestReviseContext: prompt = format_prompt(ctx, "backend_dev", "You are a developer.") assert "## Director's revision request:" not in prompt assert "## Your previous output (before revision):" not in prompt + + +# --------------------------------------------------------------------------- +# KIN-071: project_type and SSH context +# --------------------------------------------------------------------------- + +class TestOperationsProject: + """KIN-071: operations project_type propagates to context and prompt.""" + + @pytest.fixture + def ops_conn(self): + c = init_db(":memory:") + models.create_project( + c, "srv", "My Server", "", + project_type="operations", + ssh_host="10.0.0.1", + ssh_user="root", + ssh_key_path="~/.ssh/id_rsa", + ssh_proxy_jump="jumpt", + ) + models.create_task(c, "SRV-001", "srv", "Scan server") + yield c + c.close() + + def test_slim_project_includes_project_type(self, ops_conn): + """KIN-071: _slim_project включает project_type.""" + ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv") + assert ctx["project"]["project_type"] == "operations" + + def test_slim_project_includes_ssh_fields_for_operations(self, ops_conn): + """KIN-071: _slim_project включает ssh_* поля для operations-проектов.""" + ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv") + proj = ctx["project"] + assert proj["ssh_host"] == "10.0.0.1" + assert proj["ssh_user"] == "root" + assert proj["ssh_key_path"] == "~/.ssh/id_rsa" + assert proj["ssh_proxy_jump"] == "jumpt" + + def test_slim_project_no_ssh_fields_for_development(self): + """KIN-071: development-проект не получает ssh_* в slim.""" + c = init_db(":memory:") + models.create_project(c, "dev", "Dev", "/path") + models.create_task(c, "DEV-001", "dev", "A task") + ctx = build_context(c, "DEV-001", "backend_dev", "dev") + assert "ssh_host" not in ctx["project"] + c.close() + + def test_sysadmin_context_gets_decisions_and_modules(self, ops_conn): + """KIN-071: sysadmin роль получает все decisions и modules.""" + models.add_module(ops_conn, "srv", "nginx", "service", "/etc/nginx") + models.add_decision(ops_conn, "srv", "gotcha", "Port 80 in use", "conflict") + ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv") + assert "decisions" in ctx + assert "modules" in ctx + assert len(ctx["modules"]) == 1 + + def test_format_prompt_includes_ssh_connection_section(self, ops_conn): + """KIN-071: format_prompt добавляет '## SSH Connection' для operations.""" + ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv") + prompt = format_prompt(ctx, "sysadmin", "You are sysadmin.") + assert "## SSH Connection" in prompt + assert "10.0.0.1" in prompt + assert "root" in prompt + assert "jumpt" in prompt + + def test_format_prompt_no_ssh_section_for_development(self): + """KIN-071: development-проект не получает SSH-секцию в prompt.""" + c = init_db(":memory:") + models.create_project(c, "dev", "Dev", "/path") + models.create_task(c, "DEV-001", "dev", "A task") + ctx = build_context(c, "DEV-001", "backend_dev", "dev") + prompt = format_prompt(ctx, "backend_dev", "You are a dev.") + assert "## SSH Connection" not in prompt + c.close() + + def test_format_prompt_includes_project_type(self, ops_conn): + """KIN-071: format_prompt включает Project type в секцию проекта.""" + ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv") + prompt = format_prompt(ctx, "sysadmin", "You are sysadmin.") + assert "Project type: operations" in prompt diff --git a/tests/test_models.py b/tests/test_models.py index 59157c4..06ee4f9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -55,6 +55,40 @@ def test_update_project_tech_stack_json(conn): assert updated["tech_stack"] == ["python", "fastapi"] +# -- project_type and SSH fields (KIN-071) -- + +def test_create_operations_project(conn): + """KIN-071: operations project stores SSH fields.""" + p = models.create_project( + conn, "srv1", "My Server", "", + project_type="operations", + ssh_host="10.0.0.1", + ssh_user="root", + ssh_key_path="~/.ssh/id_rsa", + ssh_proxy_jump="jumpt", + ) + assert p["project_type"] == "operations" + assert p["ssh_host"] == "10.0.0.1" + assert p["ssh_user"] == "root" + assert p["ssh_key_path"] == "~/.ssh/id_rsa" + assert p["ssh_proxy_jump"] == "jumpt" + + +def test_create_development_project_defaults(conn): + """KIN-071: development is default project_type.""" + p = models.create_project(conn, "devp", "Dev Project", "/path") + assert p["project_type"] == "development" + assert p["ssh_host"] is None + + +def test_update_project_ssh_fields(conn): + """KIN-071: update_project can set SSH fields.""" + models.create_project(conn, "srv2", "Server 2", "", project_type="operations") + updated = models.update_project(conn, "srv2", ssh_host="192.168.1.1", ssh_user="pelmen") + assert updated["ssh_host"] == "192.168.1.1" + assert updated["ssh_user"] == "pelmen" + + # -- validate_completion_mode (KIN-063) -- def test_validate_completion_mode_valid_auto_complete(): diff --git a/tests/test_runner.py b/tests/test_runner.py index 61fce2b..33f30a1 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -9,6 +9,7 @@ from core import models from agents.runner import ( run_agent, run_pipeline, run_audit, _try_parse_json, _run_learning_extraction, _build_claude_env, _resolve_claude_cmd, _EXTRA_PATH_DIRS, _run_autocommit, + _parse_agent_blocked, ) @@ -1694,3 +1695,224 @@ class TestAuditLogDangerousSkip: "SELECT * FROM audit_log WHERE task_id='VDOL-001'" ).fetchall() assert len(rows) == 0 + + +# --------------------------------------------------------------------------- +# KIN-016: Blocked Protocol +# --------------------------------------------------------------------------- + +class TestParseAgentBlocked: + def test_returns_none_on_failure(self): + result = {"success": False, "output": {"status": "blocked", "reason": "no access"}} + assert _parse_agent_blocked(result) is None + + def test_returns_none_when_output_not_dict(self): + result = {"success": True, "output": "plain text output"} + assert _parse_agent_blocked(result) is None + + def test_returns_none_when_status_not_blocked(self): + result = {"success": True, "output": {"status": "done", "changes": []}} + assert _parse_agent_blocked(result) is None + + def test_detects_status_blocked(self): + result = {"success": True, "output": {"status": "blocked", "reason": "no file access"}} + blocked = _parse_agent_blocked(result) + assert blocked is not None + assert blocked["reason"] == "no file access" + assert blocked["blocked_at"] is not None + + def test_detects_verdict_blocked(self): + """reviewer.md uses verdict: blocked instead of status: blocked.""" + result = {"success": True, "output": {"verdict": "blocked", "blocked_reason": "unreadable"}} + blocked = _parse_agent_blocked(result) + assert blocked is not None + assert blocked["reason"] == "unreadable" + + def test_uses_provided_blocked_at(self): + result = {"success": True, "output": { + "status": "blocked", "reason": "out of scope", + "blocked_at": "2026-03-16T10:00:00", + }} + blocked = _parse_agent_blocked(result) + assert blocked["blocked_at"] == "2026-03-16T10:00:00" + + def test_falls_back_blocked_at_if_missing(self): + result = {"success": True, "output": {"status": "blocked", "reason": "x"}} + blocked = _parse_agent_blocked(result) + assert "T" in blocked["blocked_at"] # ISO-8601 with T separator + + def test_does_not_check_nested_status(self): + """Nested status='blocked' in sub-fields must NOT trigger blocked protocol.""" + result = {"success": True, "output": { + "status": "done", + "changes": [{"file": "a.py", "status": "blocked"}], # nested — must be ignored + }} + assert _parse_agent_blocked(result) is None + + +class TestPipelineBlockedProtocol: + @patch("agents.runner._run_autocommit") + @patch("agents.runner.subprocess.run") + def test_pipeline_stops_on_semantic_blocked(self, mock_run, mock_autocommit, conn): + """KIN-016: когда агент возвращает status='blocked', пайплайн останавливается.""" + # First step returns semantic blocked + mock_run.return_value = _mock_claude_success({ + "result": json.dumps({"status": "blocked", "reason": "cannot access external API"}), + }) + + steps = [ + {"role": "debugger", "brief": "find bug"}, + {"role": "tester", "brief": "verify"}, + ] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is False + assert result["steps_completed"] == 0 + assert "blocked" in result["error"] + assert result["blocked_by"] == "debugger" + assert result["blocked_reason"] == "cannot access external API" + + # Task marked as blocked with enriched fields + task = models.get_task(conn, "VDOL-001") + assert task["status"] == "blocked" + assert task["blocked_reason"] == "cannot access external API" + assert task["blocked_agent_role"] == "debugger" + assert task["blocked_pipeline_step"] == "1" + assert task["blocked_at"] is not None + + # Pipeline marked as failed + pipe = conn.execute("SELECT * FROM pipelines WHERE task_id='VDOL-001'").fetchone() + assert pipe["status"] == "failed" + + @patch("agents.runner._run_autocommit") + @patch("agents.runner.subprocess.run") + def test_pipeline_blocks_on_second_step(self, mock_run, mock_autocommit, conn): + """KIN-016: blocked на шаге 2 → steps_completed=1, pipeline_step='2'.""" + mock_run.side_effect = [ + _mock_claude_success({"result": json.dumps({"status": "done", "changes": []})}), + _mock_claude_success({"result": json.dumps({ + "status": "blocked", "reason": "test environment unavailable", + })}), + ] + + steps = [ + {"role": "backend_dev", "brief": "implement"}, + {"role": "tester", "brief": "test"}, + ] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is False + assert result["steps_completed"] == 1 + assert result["blocked_by"] == "tester" + + task = models.get_task(conn, "VDOL-001") + assert task["blocked_agent_role"] == "tester" + assert task["blocked_pipeline_step"] == "2" + + @patch("agents.runner._run_autocommit") + @patch("agents.runner.subprocess.run") + def test_reviewer_verdict_blocked_stops_pipeline(self, mock_run, mock_autocommit, conn): + """KIN-016: reviewer возвращает verdict='blocked' → пайплайн останавливается.""" + mock_run.return_value = _mock_claude_success({ + "result": json.dumps({ + "verdict": "blocked", "status": "blocked", + "reason": "cannot read implementation files", + }), + }) + + steps = [{"role": "reviewer", "brief": "review"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is False + assert result["blocked_by"] == "reviewer" + + task = models.get_task(conn, "VDOL-001") + assert task["status"] == "blocked" + assert task["blocked_agent_role"] == "reviewer" + + +# --------------------------------------------------------------------------- +# KIN-071: _save_sysadmin_output +# --------------------------------------------------------------------------- + +class TestSaveSysadminOutput: + """KIN-071: _save_sysadmin_output парсит и сохраняет decisions + modules.""" + + @pytest.fixture + def ops_conn(self): + c = init_db(":memory:") + models.create_project( + c, "srv", "Server", "", + project_type="operations", + ssh_host="10.0.0.1", + ) + models.create_task(c, "SRV-001", "srv", "Scan server") + yield c + c.close() + + def test_saves_decisions_and_modules(self, ops_conn): + """KIN-071: sysadmin output корректно сохраняет decisions и modules.""" + from agents.runner import _save_sysadmin_output + output = { + "status": "done", + "decisions": [ + {"type": "gotcha", "title": "Port 8080 open", "description": "nginx on 8080", "tags": ["server"]}, + {"type": "decision", "title": "Docker used", "description": "docker 24.0", "tags": ["docker"]}, + ], + "modules": [ + {"name": "nginx", "type": "service", "path": "/etc/nginx", "description": "web proxy"}, + ], + } + result = _save_sysadmin_output( + ops_conn, "srv", "SRV-001", + {"raw_output": json.dumps(output)} + ) + assert result["decisions_added"] == 2 + assert result["modules_added"] == 1 + + decisions = models.get_decisions(ops_conn, "srv") + assert len(decisions) == 2 + modules = models.get_modules(ops_conn, "srv") + assert len(modules) == 1 + assert modules[0]["name"] == "nginx" + + def test_idempotent_on_duplicate_decisions(self, ops_conn): + """KIN-071: повторный вызов не создаёт дублей.""" + from agents.runner import _save_sysadmin_output + output = { + "decisions": [ + {"type": "gotcha", "title": "Port 8080 open", "description": "nginx on 8080"}, + ], + "modules": [], + } + r1 = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)}) + r2 = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)}) + assert r1["decisions_added"] == 1 + assert r2["decisions_added"] == 0 # deduped + assert r2["decisions_skipped"] == 1 + + def test_idempotent_on_duplicate_modules(self, ops_conn): + """KIN-071: повторный вызов не создаёт дублей модулей.""" + from agents.runner import _save_sysadmin_output + output = { + "decisions": [], + "modules": [{"name": "nginx", "type": "service", "path": "/etc/nginx"}], + } + r1 = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)}) + r2 = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)}) + assert r1["modules_added"] == 1 + assert r2["modules_skipped"] == 1 + assert len(models.get_modules(ops_conn, "srv")) == 1 + + def test_handles_non_json_output(self, ops_conn): + """KIN-071: не-JSON вывод не вызывает исключения.""" + from agents.runner import _save_sysadmin_output + result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": "not json"}) + assert result["decisions_added"] == 0 + assert result["modules_added"] == 0 + + def test_handles_empty_output(self, ops_conn): + """KIN-071: пустой вывод не вызывает исключения.""" + from agents.runner import _save_sysadmin_output + result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": ""}) + assert result["decisions_added"] == 0 diff --git a/web/api.py b/web/api.py index 56ff9f7..8e0666d 100644 --- a/web/api.py +++ b/web/api.py @@ -112,6 +112,44 @@ def list_projects(status: str | None = None): return summary +class NewProjectCreate(BaseModel): + id: str + name: str + path: str + description: str + roles: list[str] + tech_stack: list[str] | None = None + priority: int = 5 + language: str = "ru" + + +@app.post("/api/projects/new") +def new_project_with_phases(body: NewProjectCreate): + """Create project + sequential research phases (KIN-059).""" + from core.phases import create_project_with_phases, validate_roles + clean_roles = validate_roles(body.roles) + if not clean_roles: + raise HTTPException(400, "At least one research role must be selected (excluding architect)") + conn = get_conn() + if models.get_project(conn, body.id): + conn.close() + raise HTTPException(409, f"Project '{body.id}' already exists") + try: + result = create_project_with_phases( + conn, body.id, body.name, body.path, + description=body.description, + selected_roles=clean_roles, + tech_stack=body.tech_stack, + priority=body.priority, + language=body.language, + ) + except ValueError as e: + conn.close() + raise HTTPException(400, str(e)) + conn.close() + return result + + @app.get("/api/projects/{project_id}") def get_project(project_id: str): conn = get_conn() @@ -126,13 +164,21 @@ def get_project(project_id: str): return {**p, "tasks": tasks, "modules": mods, "decisions": decisions} +VALID_PROJECT_TYPES = {"development", "operations", "research"} + + class ProjectCreate(BaseModel): id: str name: str - path: str + path: str = "" tech_stack: list[str] | None = None status: str = "active" priority: int = 5 + 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 class ProjectPatch(BaseModel): @@ -140,14 +186,28 @@ class ProjectPatch(BaseModel): autocommit_enabled: bool | None = None obsidian_vault_path: str | None = None deploy_command: str | None = None + project_type: str | None = None + ssh_host: str | None = None + ssh_user: str | None = None + ssh_key_path: str | None = None + ssh_proxy_jump: str | None = None @app.patch("/api/projects/{project_id}") def patch_project(project_id: str, body: ProjectPatch): - if body.execution_mode is None and body.autocommit_enabled is None and body.obsidian_vault_path is None and body.deploy_command is None: - raise HTTPException(400, "Nothing to update. Provide execution_mode, autocommit_enabled, obsidian_vault_path, or deploy_command.") + has_any = any([ + body.execution_mode, body.autocommit_enabled is not None, + body.obsidian_vault_path, body.deploy_command is not None, + body.project_type, body.ssh_host is not None, + body.ssh_user is not None, body.ssh_key_path is not None, + body.ssh_proxy_jump is not None, + ]) + if not has_any: + raise HTTPException(400, "Nothing to update.") if body.execution_mode is not None and body.execution_mode not in VALID_EXECUTION_MODES: raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}") + if body.project_type is not None and body.project_type not in VALID_PROJECT_TYPES: + raise HTTPException(400, f"Invalid project_type '{body.project_type}'. Must be one of: {', '.join(VALID_PROJECT_TYPES)}") conn = get_conn() p = models.get_project(conn, project_id) if not p: @@ -163,6 +223,16 @@ def patch_project(project_id: str, body: ProjectPatch): if body.deploy_command is not None: # Empty string = sentinel for clearing (decision #68) fields["deploy_command"] = None if body.deploy_command == "" else body.deploy_command + if body.project_type is not None: + fields["project_type"] = body.project_type + if body.ssh_host is not None: + fields["ssh_host"] = body.ssh_host + if body.ssh_user is not None: + fields["ssh_user"] = body.ssh_user + if body.ssh_key_path is not None: + fields["ssh_key_path"] = body.ssh_key_path + if body.ssh_proxy_jump is not None: + fields["ssh_proxy_jump"] = body.ssh_proxy_jump models.update_project(conn, project_id, **fields) p = models.get_project(conn, project_id) conn.close() @@ -229,6 +299,8 @@ def deploy_project(project_id: str): @app.post("/api/projects") def create_project(body: ProjectCreate): + if body.project_type not in VALID_PROJECT_TYPES: + raise HTTPException(400, f"Invalid project_type '{body.project_type}'. Must be one of: {', '.join(VALID_PROJECT_TYPES)}") conn = get_conn() if models.get_project(conn, body.id): conn.close() @@ -236,11 +308,105 @@ def create_project(body: ProjectCreate): p = models.create_project( conn, body.id, body.name, body.path, tech_stack=body.tech_stack, status=body.status, priority=body.priority, + project_type=body.project_type, + ssh_host=body.ssh_host, + ssh_user=body.ssh_user, + ssh_key_path=body.ssh_key_path, + ssh_proxy_jump=body.ssh_proxy_jump, ) conn.close() return p +# --------------------------------------------------------------------------- +# Phases (KIN-059) +# --------------------------------------------------------------------------- + +@app.get("/api/projects/{project_id}/phases") +def get_project_phases(project_id: str): + """List research phases for a project, with task data joined.""" + conn = get_conn() + p = models.get_project(conn, project_id) + if not p: + conn.close() + raise HTTPException(404, f"Project '{project_id}' not found") + phases = models.list_phases(conn, project_id) + result = [] + for phase in phases: + task = models.get_task(conn, phase["task_id"]) if phase.get("task_id") else None + result.append({**phase, "task": task}) + conn.close() + return result + + +class PhaseApprove(BaseModel): + comment: str | None = None + + +class PhaseReject(BaseModel): + reason: str + + +class PhaseRevise(BaseModel): + comment: str + + +@app.post("/api/phases/{phase_id}/approve") +def approve_phase(phase_id: int, body: PhaseApprove | None = None): + """Approve a research phase and activate the next one.""" + from core.phases import approve_phase as _approve + conn = get_conn() + phase = models.get_phase(conn, phase_id) + if not phase: + conn.close() + raise HTTPException(404, f"Phase {phase_id} not found") + try: + result = _approve(conn, phase_id) + except ValueError as e: + conn.close() + raise HTTPException(400, str(e)) + conn.close() + return result + + +@app.post("/api/phases/{phase_id}/reject") +def reject_phase(phase_id: int, body: PhaseReject): + """Reject a research phase.""" + from core.phases import reject_phase as _reject + conn = get_conn() + phase = models.get_phase(conn, phase_id) + if not phase: + conn.close() + raise HTTPException(404, f"Phase {phase_id} not found") + try: + result = _reject(conn, phase_id, body.reason) + except ValueError as e: + conn.close() + raise HTTPException(400, str(e)) + conn.close() + return result + + +@app.post("/api/phases/{phase_id}/revise") +def revise_phase(phase_id: int, body: PhaseRevise): + """Request revision for a research phase.""" + from core.phases import revise_phase as _revise + if not body.comment.strip(): + raise HTTPException(400, "comment is required") + conn = get_conn() + phase = models.get_phase(conn, phase_id) + if not phase: + conn.close() + raise HTTPException(404, f"Phase {phase_id} not found") + try: + result = _revise(conn, phase_id, body.comment) + except ValueError as e: + conn.close() + raise HTTPException(400, str(e)) + conn.close() + return result + + # --------------------------------------------------------------------------- # Tasks # --------------------------------------------------------------------------- @@ -716,6 +882,46 @@ def bootstrap(body: BootstrapRequest): } +# --------------------------------------------------------------------------- +# Notifications (escalations from blocked agents) +# --------------------------------------------------------------------------- + +@app.get("/api/notifications") +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). + """ + conn = get_conn() + query = "SELECT * FROM tasks WHERE status = 'blocked'" + params: list = [] + if project_id: + query += " AND project_id = ?" + params.append(project_id) + query += " ORDER BY blocked_at DESC, updated_at DESC" + rows = conn.execute(query, params).fetchall() + conn.close() + + notifications = [] + for row in rows: + t = dict(row) + notifications.append({ + "task_id": t["id"], + "project_id": t["project_id"], + "title": t.get("title"), + "agent_role": t.get("blocked_agent_role"), + "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, + }) + return notifications + + # --------------------------------------------------------------------------- # SPA static files (AFTER all /api/ routes) # --------------------------------------------------------------------------- diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue index cf79de5..0186755 100644 --- a/web/frontend/src/App.vue +++ b/web/frontend/src/App.vue @@ -1,4 +1,5 @@