diff --git a/agents/prompts/constitution.md b/agents/prompts/constitution.md new file mode 100644 index 0000000..44aebb9 --- /dev/null +++ b/agents/prompts/constitution.md @@ -0,0 +1,37 @@ +You are a Constitution Agent for a software project. + +Your job: define the project's core principles, hard constraints, and strategic goals. +These form the non-negotiable foundation for all subsequent design and implementation decisions. + +## Your output format (JSON only) + +Return ONLY valid JSON — no markdown, no explanation: + +```json +{ + "principles": [ + "Simplicity over cleverness — prefer readable code", + "Security by default — no plaintext secrets", + "..." + ], + "constraints": [ + "Must use Python 3.11+", + "No external paid APIs without fallback", + "..." + ], + "goals": [ + "Enable solo developer to ship features 10x faster via AI agents", + "..." + ] +} +``` + +## Instructions + +1. Read the project path, tech stack, task brief, and previous outputs provided below +2. Analyze existing CLAUDE.md, README, or design documents if available +3. Infer principles from existing code style and patterns +4. Identify hard constraints (technology, security, performance, regulatory) +5. Articulate 3-7 high-level goals this project exists to achieve + +Keep each item concise (1-2 sentences max). diff --git a/agents/prompts/spec.md b/agents/prompts/spec.md new file mode 100644 index 0000000..8420978 --- /dev/null +++ b/agents/prompts/spec.md @@ -0,0 +1,45 @@ +You are a Specification Agent for a software project. + +Your job: create a detailed feature specification based on the project constitution +(provided as "Previous step output") and the task brief. + +## Your output format (JSON only) + +Return ONLY valid JSON — no markdown, no explanation: + +```json +{ + "overview": "One paragraph summary of what is being built and why", + "features": [ + { + "name": "User Authentication", + "description": "Email + password login with JWT tokens", + "acceptance_criteria": "User can log in, receives token, token expires in 24h" + } + ], + "data_model": [ + { + "entity": "User", + "fields": ["id UUID", "email TEXT UNIQUE", "password_hash TEXT", "created_at DATETIME"] + } + ], + "api_contracts": [ + { + "method": "POST", + "path": "/api/auth/login", + "body": {"email": "string", "password": "string"}, + "response": {"token": "string", "expires_at": "ISO-8601"} + } + ], + "acceptance_criteria": "Full set of acceptance criteria for the entire spec" +} +``` + +## Instructions + +1. The **Previous step output** contains the constitution (principles, constraints, goals) +2. Respect ALL constraints from the constitution — do not violate them +3. Design features that advance the stated goals +4. Keep the data model minimal — only what is needed +5. API contracts must be consistent with existing project patterns +6. Acceptance criteria must be testable and specific diff --git a/agents/prompts/task_decomposer.md b/agents/prompts/task_decomposer.md new file mode 100644 index 0000000..d3b37a3 --- /dev/null +++ b/agents/prompts/task_decomposer.md @@ -0,0 +1,43 @@ +You are a Task Decomposer Agent for a software project. + +Your job: take an architect's implementation plan (provided as "Previous step output") +and break it down into concrete, actionable implementation tasks. + +## Your output format (JSON only) + +Return ONLY valid JSON — no markdown, no explanation: + +```json +{ + "tasks": [ + { + "title": "Add user_sessions table to core/db.py", + "brief": "Create table with columns: id, user_id, token_hash, expires_at, created_at. Add migration in _migrate().", + "priority": 3, + "category": "DB", + "acceptance_criteria": "Table created in SQLite, migration idempotent, existing DB unaffected" + }, + { + "title": "Implement POST /api/auth/login endpoint", + "brief": "Validate email/password, generate JWT, store session, return token. Use bcrypt for password verification.", + "priority": 3, + "category": "API", + "acceptance_criteria": "Returns 200 with token on valid credentials, 401 on invalid, 422 on missing fields" + } + ] +} +``` + +## Valid categories + +DB, API, UI, INFRA, SEC, BIZ, ARCH, TEST, PERF, DOCS, FIX, OBS + +## Instructions + +1. The **Previous step output** contains the architect's implementation plan +2. Create one task per discrete implementation unit (file, function group, endpoint) +3. Tasks should be independent and completable in a single agent session +4. Priority: 1 = critical, 3 = normal, 5 = low +5. Each task must have clear, testable acceptance criteria +6. Do NOT include tasks for writing documentation unless explicitly in the spec +7. Aim for 3-10 tasks — if you need more, group related items diff --git a/agents/runner.py b/agents/runner.py index 2086f48..d37300c 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -54,6 +54,19 @@ def _build_claude_env() -> dict: seen.add(d) deduped.append(d) env["PATH"] = ":".join(deduped) + + # Ensure SSH agent is available for agents that connect via SSH. + # Under launchd, SSH_AUTH_SOCK is not inherited — detect macOS system socket. + if "SSH_AUTH_SOCK" not in env: + import glob + socks = glob.glob("/private/tmp/com.apple.launchd.*/Listeners") + if socks: + env["SSH_AUTH_SOCK"] = socks[0] + if "SSH_AGENT_PID" not in env: + pid = os.environ.get("SSH_AGENT_PID") + if pid: + env["SSH_AGENT_PID"] = pid + return env @@ -127,6 +140,7 @@ def run_agent( dry_run: bool = False, allow_write: bool = False, noninteractive: bool = False, + working_dir_override: str | None = None, ) -> dict: """Run a single Claude Code agent as a subprocess. @@ -161,7 +175,9 @@ def run_agent( working_dir = None # 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"): + if working_dir_override: + working_dir = working_dir_override + elif not is_operations and project and role in ("debugger", "frontend_dev", "backend_dev", "tester", "security", "constitution", "spec", "task_decomposer"): project_path = Path(project["path"]).expanduser() if project_path.is_dir(): working_dir = str(project_path) @@ -685,6 +701,151 @@ def _save_sysadmin_output( } +# --------------------------------------------------------------------------- +# Auto-test: detect test failure in agent output +# --------------------------------------------------------------------------- + +_TEST_FAILURE_PATTERNS = [ + r"\bFAILED\b", + r"\bFAIL\b", + r"\d+\s+failed", + r"test(?:s)?\s+failed", + r"assert(?:ion)?\s*(error|failed)", + r"exception(?:s)?\s+occurred", + r"returncode\s*[!=]=\s*0", + r"Error:\s", + r"ERRORS?\b", +] + +_TEST_SUCCESS_PATTERNS = [ + r"no\s+failures", + r"all\s+tests?\s+pass", + r"0\s+failed", + r"passed.*no\s+errors", +] + + +def _is_test_failure(result: dict) -> bool: + """Return True if agent output indicates test failures. + + Checks for failure keywords, guards against false positives from + explicit success phrases (e.g. 'no failures'). + """ + output = result.get("raw_output") or result.get("output") or "" + if not isinstance(output, str): + output = json.dumps(output, ensure_ascii=False) + + for p in _TEST_SUCCESS_PATTERNS: + if re.search(p, output, re.IGNORECASE): + return False + + for p in _TEST_FAILURE_PATTERNS: + if re.search(p, output, re.IGNORECASE): + return True + + return False + + +# --------------------------------------------------------------------------- +# Auto-test runner: run project tests via `make test` +# --------------------------------------------------------------------------- + +# Roles that trigger auto-test when project.auto_test_enabled is set +_AUTO_TEST_ROLES = {"backend_dev", "frontend_dev"} + + +def _run_project_tests(project_path: str, timeout: int = 120) -> dict: + """Run `make test` in project_path. Returns {success, output, returncode}. + + Never raises — all errors are captured and returned in output. + """ + env = _build_claude_env() + make_cmd = shutil.which("make", path=env["PATH"]) or "make" + try: + result = subprocess.run( + [make_cmd, "test"], + cwd=project_path, + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + output = (result.stdout or "") + (result.stderr or "") + return {"success": result.returncode == 0, "output": output, "returncode": result.returncode} + except subprocess.TimeoutExpired: + return {"success": False, "output": f"make test timed out after {timeout}s", "returncode": 124} + except FileNotFoundError: + return {"success": False, "output": "make not found — no Makefile or make not in PATH", "returncode": 127} + except Exception as exc: + return {"success": False, "output": f"Test run error: {exc}", "returncode": -1} + + +# --------------------------------------------------------------------------- +# Decomposer output: create child tasks from task_decomposer JSON +# --------------------------------------------------------------------------- + +def _save_decomposer_output( + conn: sqlite3.Connection, + project_id: str, + parent_task_id: str, + result: dict, +) -> dict: + """Parse task_decomposer output and create child tasks in DB. + + Expected output format: {tasks: [{title, brief, priority, category, acceptance_criteria}]} + Idempotent: skips tasks with same parent_task_id + title (case-insensitive). + Returns {created: int, skipped: int}. + """ + 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 {"created": 0, "skipped": 0, "error": "non-JSON decomposer output"} + + task_list = parsed.get("tasks", []) + if not isinstance(task_list, list): + return {"created": 0, "skipped": 0, "error": "invalid tasks format"} + + created = 0 + skipped = 0 + for item in task_list: + if not isinstance(item, dict): + continue + title = (item.get("title") or "").strip() + if not title: + continue + # Idempotency: skip if same parent + title already exists + existing = conn.execute( + """SELECT id FROM tasks + WHERE parent_task_id = ? AND lower(trim(title)) = lower(trim(?))""", + (parent_task_id, title), + ).fetchone() + if existing: + skipped += 1 + continue + category = (item.get("category") or "").strip().upper() + if category not in models.TASK_CATEGORIES: + category = None + task_id = models.next_task_id(conn, project_id, category=category) + brief_text = item.get("brief") or "" + models.create_task( + conn, + task_id, + project_id, + title, + priority=item.get("priority", 5), + brief={"text": brief_text, "source": f"decomposer:{parent_task_id}"}, + category=category, + acceptance_criteria=item.get("acceptance_criteria"), + parent_task_id=parent_task_id, + ) + created += 1 + + return {"created": created, "skipped": skipped} + + # --------------------------------------------------------------------------- # Auto-learning: extract decisions from pipeline results # --------------------------------------------------------------------------- @@ -866,6 +1027,26 @@ def run_pipeline( model = step.get("model", "sonnet") brief = step.get("brief") + # Worktree isolation: opt-in per project, for write-capable roles + _WORKTREE_ROLES = {"backend_dev", "frontend_dev", "debugger"} + worktree_path = None + project_for_wt = models.get_project(conn, task["project_id"]) if not dry_run else None + use_worktree = ( + not dry_run + and role in _WORKTREE_ROLES + and project_for_wt + and project_for_wt.get("worktrees_enabled") + and project_for_wt.get("path") + ) + if use_worktree: + try: + from core.worktree import create_worktree, ensure_gitignore + p_path = str(Path(project_for_wt["path"]).expanduser()) + ensure_gitignore(p_path) + worktree_path = create_worktree(p_path, task_id, role) + except Exception: + worktree_path = None # Fall back to normal execution + try: result = run_agent( conn, role, task_id, project_id, @@ -875,6 +1056,7 @@ def run_pipeline( dry_run=dry_run, allow_write=allow_write, noninteractive=noninteractive, + working_dir_override=worktree_path, ) except Exception as exc: exc_msg = f"Step {i+1}/{len(steps)} ({role}) raised exception: {exc}" @@ -999,6 +1181,44 @@ def run_pipeline( "pipeline_id": pipeline["id"] if pipeline else None, } + # Worktree merge/cleanup after successful step + if worktree_path and result["success"] and not dry_run: + try: + from core.worktree import merge_worktree, cleanup_worktree + p_path = str(Path(project_for_wt["path"]).expanduser()) + merge_result = merge_worktree(worktree_path, p_path) + if not merge_result["success"]: + conflicts = merge_result.get("conflicts", []) + conflict_msg = f"Worktree merge conflict in files: {', '.join(conflicts)}" if conflicts else "Worktree merge failed" + models.update_task(conn, task_id, status="blocked", blocked_reason=conflict_msg) + cleanup_worktree(worktree_path, p_path) + if pipeline: + models.update_pipeline(conn, pipeline["id"], status="failed", + total_cost_usd=total_cost, + total_tokens=total_tokens, + total_duration_seconds=total_duration) + return { + "success": False, + "error": conflict_msg, + "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, + } + cleanup_worktree(worktree_path, p_path) + except Exception: + pass # Worktree errors must never block pipeline + elif worktree_path and not dry_run: + # Step failed — cleanup worktree without merging + try: + from core.worktree import cleanup_worktree + p_path = str(Path(project_for_wt["path"]).expanduser()) + cleanup_worktree(worktree_path, p_path) + except Exception: + pass + results.append(result) # Semantic blocked: agent ran successfully but returned status='blocked' @@ -1056,6 +1276,137 @@ def run_pipeline( except Exception: pass # Never block pipeline on sysadmin save errors + # Save decomposer output: create child tasks from task_decomposer JSON + if role == "task_decomposer" and result["success"] and not dry_run: + try: + _save_decomposer_output(conn, project_id, task_id, result) + except Exception: + pass # Never block pipeline on decomposer save errors + + # Project-level auto-test: run `make test` after backend_dev/frontend_dev steps. + # Enabled per project via auto_test_enabled flag (opt-in). + # On failure, loop fixer up to KIN_AUTO_TEST_MAX_ATTEMPTS times, then block. + if ( + not dry_run + and role in _AUTO_TEST_ROLES + and result["success"] + and project_for_wt + and project_for_wt.get("auto_test_enabled") + and project_for_wt.get("path") + ): + max_auto_test_attempts = int(os.environ.get("KIN_AUTO_TEST_MAX_ATTEMPTS") or 3) + p_path_str = str(Path(project_for_wt["path"]).expanduser()) + test_run = _run_project_tests(p_path_str) + results.append({"role": "_auto_test", "success": test_run["success"], + "output": test_run["output"], "_project_test": True}) + auto_test_attempt = 0 + while not test_run["success"] and auto_test_attempt < max_auto_test_attempts: + auto_test_attempt += 1 + fix_context = ( + f"Automated project test run (make test) failed after your changes.\n" + f"Test output:\n{test_run['output'][:4000]}\n" + f"Fix the failing tests. Do NOT modify test files." + ) + fix_result = run_agent( + conn, role, task_id, project_id, + model=model, + previous_output=fix_context, + dry_run=False, + allow_write=allow_write, + noninteractive=noninteractive, + ) + total_cost += fix_result.get("cost_usd") or 0 + total_tokens += fix_result.get("tokens_used") or 0 + total_duration += fix_result.get("duration_seconds") or 0 + results.append({**fix_result, "_auto_test_fix_attempt": auto_test_attempt}) + test_run = _run_project_tests(p_path_str) + results.append({"role": "_auto_test", "success": test_run["success"], + "output": test_run["output"], "_project_test": True, + "_attempt": auto_test_attempt}) + if not test_run["success"]: + block_reason = ( + f"Auto-test (make test) failed after {auto_test_attempt} fix attempt(s). " + f"Last output: {test_run['output'][:500]}" + ) + models.update_task(conn, task_id, status="blocked", blocked_reason=block_reason) + if pipeline: + models.update_pipeline(conn, pipeline["id"], status="failed", + total_cost_usd=total_cost, + total_tokens=total_tokens, + total_duration_seconds=total_duration) + return { + "success": False, + "error": block_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, + } + + # Auto-test loop: if tester step has auto_fix=true and tests failed, + # call fix_role agent and re-run tester up to max_attempts times. + if ( + not dry_run + and step.get("auto_fix") + and role == "tester" + and result["success"] + and _is_test_failure(result) + ): + max_attempts = int(step.get("max_attempts", 3)) + fix_role = step.get("fix_role", "backend_dev") + fix_model = step.get("fix_model", model) + attempt = 0 + while attempt < max_attempts and _is_test_failure(result): + attempt += 1 + tester_output = result.get("raw_output") or result.get("output") or "" + if isinstance(tester_output, (dict, list)): + tester_output = json.dumps(tester_output, ensure_ascii=False) + + # Run fixer + fix_result = run_agent( + conn, fix_role, task_id, project_id, + model=fix_model, + previous_output=tester_output, + dry_run=False, + allow_write=allow_write, + noninteractive=noninteractive, + ) + total_cost += fix_result.get("cost_usd") or 0 + total_tokens += fix_result.get("tokens_used") or 0 + total_duration += fix_result.get("duration_seconds") or 0 + results.append({**fix_result, "_auto_fix_attempt": attempt}) + + # Re-run tester + fix_output = fix_result.get("raw_output") or fix_result.get("output") or "" + if isinstance(fix_output, (dict, list)): + fix_output = json.dumps(fix_output, ensure_ascii=False) + retest = run_agent( + conn, role, task_id, project_id, + model=model, + previous_output=fix_output, + dry_run=False, + allow_write=allow_write, + noninteractive=noninteractive, + ) + total_cost += retest.get("cost_usd") or 0 + total_tokens += retest.get("tokens_used") or 0 + total_duration += retest.get("duration_seconds") or 0 + result = retest + results.append({**result, "_auto_retest_attempt": attempt}) + + # Save final test result regardless of outcome + try: + final_output = result.get("raw_output") or result.get("output") or "" + models.update_task(conn, task_id, test_result={ + "output": final_output if isinstance(final_output, str) else str(final_output), + "auto_fix_attempts": attempt, + "passed": not _is_test_failure(result), + }) + except Exception: + pass + # 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 2b61003..6c8f07c 100644 --- a/agents/specialists.yaml +++ b/agents/specialists.yaml @@ -111,6 +111,46 @@ specialists: codebase_diff: "array of { file, line_hint, issue, suggestion }" notes: string + constitution: + name: "Constitution Agent" + model: sonnet + tools: [Read, Grep, Glob] + description: "Defines project principles, constraints, and non-negotiables. First step in spec-driven workflow." + permissions: read_only + context_rules: + decisions: all + output_schema: + principles: "array of strings" + constraints: "array of strings" + goals: "array of strings" + + spec: + name: "Spec Agent" + model: sonnet + tools: [Read, Grep, Glob] + description: "Creates detailed feature specification from constitution output. Second step in spec-driven workflow." + permissions: read_only + context_rules: + decisions: all + output_schema: + overview: string + features: "array of { name, description, acceptance_criteria }" + data_model: "array of { entity, fields }" + api_contracts: "array of { method, path, body, response }" + acceptance_criteria: string + + task_decomposer: + name: "Task Decomposer" + model: sonnet + tools: [Read, Grep, Glob] + description: "Decomposes architect output into concrete implementation tasks. Creates child tasks in DB." + permissions: read_only + context_rules: + decisions: all + modules: all + output_schema: + tasks: "array of { title, brief, priority, category, acceptance_criteria }" + # Route templates — PM uses these to build pipelines routes: debug: @@ -144,3 +184,7 @@ routes: infra_debug: steps: [sysadmin, debugger, reviewer] description: "SSH diagnose → find root cause → verify fix plan" + + spec_driven: + steps: [constitution, spec, architect, task_decomposer] + description: "Constitution → spec → implementation plan → decompose into tasks" diff --git a/core/context_builder.py b/core/context_builder.py index 41d478e..88e6c9d 100644 --- a/core/context_builder.py +++ b/core/context_builder.py @@ -42,9 +42,9 @@ def build_context( } # Attachments — all roles get them so debugger sees screenshots, UX sees mockups, etc. + # Initialize before conditional to guarantee key presence in ctx (#213) attachments = models.list_attachments(conn, task_id) - if attachments: - ctx["attachments"] = attachments + ctx["attachments"] = attachments # If task has a revise comment, fetch the last agent output for context if task and task.get("revise_comment"): @@ -97,6 +97,15 @@ def build_context( # Minimal context — just the task spec pass + elif role in ("constitution", "spec"): + ctx["modules"] = models.get_modules(conn, project_id) + ctx["decisions"] = models.get_decisions(conn, project_id) + + elif role == "task_decomposer": + ctx["modules"] = models.get_modules(conn, project_id) + ctx["decisions"] = models.get_decisions(conn, project_id) + ctx["active_tasks"] = models.list_tasks(conn, project_id=project_id, status="in_progress") + elif role == "security": ctx["decisions"] = models.get_decisions( conn, project_id, category="security", @@ -279,7 +288,22 @@ def format_prompt(context: dict, role: str, prompt_template: str | None = None) if attachments: sections.append(f"## Attachments ({len(attachments)}):") for a in attachments: - sections.append(f"- {a['filename']}: {a['path']}") + mime = a.get("mime_type", "") + size = a.get("size", 0) + sections.append(f"- {a['filename']} ({mime}, {size} bytes): {a['path']}") + # Inline content for small text-readable files (<= 32 KB) so PM can use them immediately + _TEXT_TYPES = {"text/", "application/json", "application/xml", "application/yaml"} + _TEXT_EXTS = {".txt", ".md", ".json", ".yaml", ".yml", ".csv", ".log", ".xml", ".toml", ".ini", ".env"} + is_text = ( + any(mime.startswith(t) if t.endswith("/") else mime == t for t in _TEXT_TYPES) + or Path(a["filename"]).suffix.lower() in _TEXT_EXTS + ) + if is_text and 0 < size <= 32 * 1024: + try: + content = Path(a["path"]).read_text(encoding="utf-8", errors="replace") + sections.append(f"```\n{content}\n```") + except Exception: + pass sections.append("") # Previous step output (pipeline chaining) diff --git a/core/db.py b/core/db.py index 8cd78e5..8bbb5f1 100644 --- a/core/db.py +++ b/core/db.py @@ -31,6 +31,8 @@ CREATE TABLE IF NOT EXISTS projects ( description TEXT, autocommit_enabled INTEGER DEFAULT 0, obsidian_vault_path TEXT, + worktrees_enabled INTEGER DEFAULT 0, + auto_test_enabled INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -56,6 +58,9 @@ CREATE TABLE IF NOT EXISTS tasks ( blocked_pipeline_step TEXT, dangerously_skipped BOOLEAN DEFAULT 0, revise_comment TEXT, + revise_count INTEGER DEFAULT 0, + revise_target_role TEXT DEFAULT NULL, + labels JSON, category TEXT DEFAULT NULL, telegram_sent BOOLEAN DEFAULT 0, acceptance_criteria TEXT, @@ -341,10 +346,30 @@ def _migrate(conn: sqlite3.Connection): conn.execute("ALTER TABLE tasks ADD COLUMN acceptance_criteria TEXT") conn.commit() + if "revise_count" not in task_cols: + conn.execute("ALTER TABLE tasks ADD COLUMN revise_count INTEGER DEFAULT 0") + conn.commit() + + if "labels" not in task_cols: + conn.execute("ALTER TABLE tasks ADD COLUMN labels JSON DEFAULT NULL") + conn.commit() + + if "revise_target_role" not in task_cols: + conn.execute("ALTER TABLE tasks ADD COLUMN revise_target_role TEXT DEFAULT NULL") + conn.commit() + if "obsidian_vault_path" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT") conn.commit() + if "worktrees_enabled" not in proj_cols: + conn.execute("ALTER TABLE projects ADD COLUMN worktrees_enabled INTEGER DEFAULT 0") + conn.commit() + + if "auto_test_enabled" not in proj_cols: + conn.execute("ALTER TABLE projects ADD COLUMN auto_test_enabled INTEGER DEFAULT 0") + conn.commit() + if "deploy_command" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN deploy_command TEXT") conn.commit() diff --git a/core/models.py b/core/models.py index d301301..e06cf9b 100644 --- a/core/models.py +++ b/core/models.py @@ -210,16 +210,18 @@ def create_task( execution_mode: str | None = None, category: str | None = None, acceptance_criteria: str | None = None, + labels: list | None = None, ) -> dict: """Create a task linked to a project.""" conn.execute( """INSERT INTO tasks (id, project_id, title, status, priority, assigned_role, parent_task_id, brief, spec, forgejo_issue_id, - execution_mode, category, acceptance_criteria) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + execution_mode, category, acceptance_criteria, labels) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (id, project_id, title, status, priority, assigned_role, parent_task_id, _json_encode(brief), _json_encode(spec), - forgejo_issue_id, execution_mode, category, acceptance_criteria), + forgejo_issue_id, execution_mode, category, acceptance_criteria, + _json_encode(labels)), ) conn.commit() return get_task(conn, id) @@ -253,7 +255,7 @@ def update_task(conn: sqlite3.Connection, id: str, **fields) -> dict: """Update task fields. Auto-sets updated_at.""" if not fields: return get_task(conn, id) - json_cols = ("brief", "spec", "review", "test_result", "security_result") + json_cols = ("brief", "spec", "review", "test_result", "security_result", "labels") for key in json_cols: if key in fields: fields[key] = _json_encode(fields[key]) diff --git a/core/worktree.py b/core/worktree.py new file mode 100644 index 0000000..1062766 --- /dev/null +++ b/core/worktree.py @@ -0,0 +1,149 @@ +""" +Kin — Git worktree management for isolated agent execution. + +Each eligible agent step gets its own worktree in {project_path}/.kin_worktrees/ +to prevent file-write conflicts between parallel or sequential agents. + +All functions are defensive: never raise, always log warnings on error. +""" + +import logging +import shutil +import subprocess +from pathlib import Path + +_logger = logging.getLogger("kin.worktree") + + +def _git(project_path: str) -> str: + """Resolve git executable, preferring extended PATH.""" + try: + from agents.runner import _build_claude_env + env = _build_claude_env() + found = shutil.which("git", path=env["PATH"]) + return found or "git" + except Exception: + return shutil.which("git") or "git" + + +def create_worktree(project_path: str, task_id: str, step_name: str = "step") -> str | None: + """Create a git worktree for isolated agent execution. + + Creates: {project_path}/.kin_worktrees/{task_id}-{step_name} + Branch name equals the worktree directory name. + + Returns the absolute worktree path, or None on any failure. + """ + git = _git(project_path) + safe_step = step_name.replace("/", "_").replace(" ", "_") + branch_name = f"{task_id}-{safe_step}" + worktrees_dir = Path(project_path) / ".kin_worktrees" + worktree_path = worktrees_dir / branch_name + + try: + worktrees_dir.mkdir(exist_ok=True) + r = subprocess.run( + [git, "worktree", "add", "-b", branch_name, str(worktree_path), "HEAD"], + cwd=project_path, + capture_output=True, + text=True, + timeout=30, + ) + if r.returncode != 0: + _logger.warning("git worktree add failed for %s: %s", branch_name, r.stderr.strip()) + return None + _logger.info("Created worktree: %s", worktree_path) + return str(worktree_path) + except Exception as exc: + _logger.warning("create_worktree error for %s: %s", branch_name, exc) + return None + + +def merge_worktree(worktree_path: str, project_path: str) -> dict: + """Merge the worktree branch back into current HEAD of project_path. + + Branch name is derived from the worktree directory name. + On conflict: aborts merge and returns success=False with conflict list. + + Returns {success: bool, conflicts: list[str], merged_files: list[str]} + """ + git = _git(project_path) + branch_name = Path(worktree_path).name + + try: + merge_result = subprocess.run( + [git, "-C", project_path, "merge", "--no-ff", branch_name], + capture_output=True, + text=True, + timeout=60, + ) + if merge_result.returncode == 0: + diff_result = subprocess.run( + [git, "-C", project_path, "diff", "HEAD~1", "HEAD", "--name-only"], + capture_output=True, + text=True, + timeout=10, + ) + merged_files = [ + f.strip() for f in diff_result.stdout.splitlines() if f.strip() + ] + _logger.info("Merged worktree %s: %d files", branch_name, len(merged_files)) + return {"success": True, "conflicts": [], "merged_files": merged_files} + + # Merge failed — collect conflicts and abort + conflict_result = subprocess.run( + [git, "-C", project_path, "diff", "--name-only", "--diff-filter=U"], + capture_output=True, + text=True, + timeout=10, + ) + conflicts = [f.strip() for f in conflict_result.stdout.splitlines() if f.strip()] + + subprocess.run( + [git, "-C", project_path, "merge", "--abort"], + capture_output=True, + timeout=10, + ) + _logger.warning("Merge conflict in worktree %s: %s", branch_name, conflicts) + return {"success": False, "conflicts": conflicts, "merged_files": []} + + except Exception as exc: + _logger.warning("merge_worktree error for %s: %s", branch_name, exc) + return {"success": False, "conflicts": [], "merged_files": [], "error": str(exc)} + + +def cleanup_worktree(worktree_path: str, project_path: str) -> None: + """Remove the git worktree and its branch. Never raises.""" + git = _git(project_path) + branch_name = Path(worktree_path).name + + try: + subprocess.run( + [git, "-C", project_path, "worktree", "remove", "--force", worktree_path], + capture_output=True, + timeout=30, + ) + subprocess.run( + [git, "-C", project_path, "branch", "-D", branch_name], + capture_output=True, + timeout=10, + ) + _logger.info("Cleaned up worktree: %s", worktree_path) + except Exception as exc: + _logger.warning("cleanup_worktree error for %s: %s", branch_name, exc) + + +def ensure_gitignore(project_path: str) -> None: + """Ensure .kin_worktrees/ is in project's .gitignore. Never raises.""" + entry = ".kin_worktrees/" + gitignore = Path(project_path) / ".gitignore" + try: + if gitignore.exists(): + content = gitignore.read_text() + if entry not in content: + with gitignore.open("a") as f: + f.write(f"\n{entry}\n") + else: + gitignore.write_text(f"{entry}\n") + except Exception as exc: + _logger.warning("ensure_gitignore error: %s", exc) diff --git a/tests/test_context_builder.py b/tests/test_context_builder.py index 1efc1a8..b659082 100644 --- a/tests/test_context_builder.py +++ b/tests/test_context_builder.py @@ -448,11 +448,12 @@ class TestAttachmentsInContext: assert "mockup.jpg" in filenames assert "/tmp/prj/.kin/attachments/PRJ-001/screenshot.png" in paths - def test_build_context_no_attachments_key_when_empty(self, conn): - """KIN-090: ключ 'attachments' отсутствует в контексте, если вложений нет.""" + def test_build_context_attachments_key_always_present(self, conn): + """KIN-094 #213: ключ 'attachments' всегда присутствует в контексте (пустой список если нет вложений).""" # conn fixture has no attachments ctx = build_context(conn, "VDOL-001", "debugger", "vdol") - assert "attachments" not in ctx + assert "attachments" in ctx + assert ctx["attachments"] == [] def test_all_roles_get_attachments(self, conn_with_attachments): """KIN-090: AC2 — все роли (debugger, pm, tester, reviewer) получают вложения.""" @@ -473,3 +474,193 @@ class TestAttachmentsInContext: ctx = build_context(conn, "VDOL-001", "debugger", "vdol") prompt = format_prompt(ctx, "debugger", "Debug this.") assert "## Attachments" not in prompt + + +# --------------------------------------------------------------------------- +# KIN-094: Attachments — ctx["attachments"] always present + inline text content +# --------------------------------------------------------------------------- + +class TestAttachmentsKIN094: + """KIN-094: AC3 — PM и другие агенты всегда получают ключ attachments в контексте; + текстовые файлы <= 32 KB вставляются inline в промпт.""" + + @pytest.fixture + def conn_no_attachments(self): + c = init_db(":memory:") + models.create_project(c, "prj", "Prj", "/tmp/prj") + models.create_task(c, "PRJ-001", "prj", "Task") + yield c + c.close() + + @pytest.fixture + def conn_text_attachment(self, tmp_path): + """Проект с текстовым вложением <= 32 KB на диске.""" + c = init_db(":memory:") + models.create_project(c, "prj", "Prj", str(tmp_path)) + models.create_task(c, "PRJ-001", "prj", "Task") + txt_file = tmp_path / "spec.txt" + txt_file.write_text("Привет, это спека задачи", encoding="utf-8") + models.create_attachment( + c, "PRJ-001", "spec.txt", str(txt_file), "text/plain", txt_file.stat().st_size, + ) + yield c + c.close() + + @pytest.fixture + def conn_md_attachment(self, tmp_path): + """Проект с .md вложением (text/markdown или определяется по расширению).""" + c = init_db(":memory:") + models.create_project(c, "prj", "Prj", str(tmp_path)) + models.create_task(c, "PRJ-001", "prj", "Task") + md_file = tmp_path / "README.md" + md_file.write_text("# Title\n\nContent of readme", encoding="utf-8") + models.create_attachment( + c, "PRJ-001", "README.md", str(md_file), "text/markdown", md_file.stat().st_size, + ) + yield c + c.close() + + @pytest.fixture + def conn_json_attachment(self, tmp_path): + """Проект с JSON-вложением (application/json).""" + c = init_db(":memory:") + models.create_project(c, "prj", "Prj", str(tmp_path)) + models.create_task(c, "PRJ-001", "prj", "Task") + json_file = tmp_path / "config.json" + json_file.write_text('{"key": "value"}', encoding="utf-8") + models.create_attachment( + c, "PRJ-001", "config.json", str(json_file), "application/json", json_file.stat().st_size, + ) + yield c + c.close() + + @pytest.fixture + def conn_large_text_attachment(self, tmp_path): + """Проект с текстовым вложением > 32 KB (не должно инлайниться).""" + c = init_db(":memory:") + models.create_project(c, "prj", "Prj", str(tmp_path)) + models.create_task(c, "PRJ-001", "prj", "Task") + big_file = tmp_path / "big.txt" + big_file.write_text("x" * (32 * 1024 + 1), encoding="utf-8") + models.create_attachment( + c, "PRJ-001", "big.txt", str(big_file), "text/plain", big_file.stat().st_size, + ) + yield c + c.close() + + @pytest.fixture + def conn_image_attachment(self, tmp_path): + """Проект с бинарным PNG-вложением (не должно инлайниться).""" + c = init_db(":memory:") + models.create_project(c, "prj", "Prj", str(tmp_path)) + models.create_task(c, "PRJ-001", "prj", "Task") + png_file = tmp_path / "screen.png" + png_file.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64) + models.create_attachment( + c, "PRJ-001", "screen.png", str(png_file), "image/png", png_file.stat().st_size, + ) + yield c + c.close() + + # ------------------------------------------------------------------ + # ctx["attachments"] always present + # ------------------------------------------------------------------ + + def test_pm_context_attachments_empty_list_when_no_attachments(self, conn_no_attachments): + """KIN-094: PM получает пустой список attachments, а не отсутствующий ключ.""" + ctx = build_context(conn_no_attachments, "PRJ-001", "pm", "prj") + assert "attachments" in ctx + assert ctx["attachments"] == [] + + def test_all_roles_attachments_key_present_when_empty(self, conn_no_attachments): + """KIN-094: все роли получают ключ attachments (пустой список) даже без вложений.""" + for role in ("pm", "debugger", "tester", "reviewer", "backend_dev", "frontend_dev", "architect"): + ctx = build_context(conn_no_attachments, "PRJ-001", role, "prj") + assert "attachments" in ctx, f"Role '{role}' missing 'attachments' key" + assert isinstance(ctx["attachments"], list), f"Role '{role}': attachments is not a list" + + # ------------------------------------------------------------------ + # Inline content for small text files + # ------------------------------------------------------------------ + + def test_format_prompt_inlines_small_text_file_content(self, conn_text_attachment): + """KIN-094: содержимое текстового файла <= 32 KB вставляется inline в промпт.""" + ctx = build_context(conn_text_attachment, "PRJ-001", "pm", "prj") + prompt = format_prompt(ctx, "pm", "You are PM.") + assert "Привет, это спека задачи" in prompt + + def test_format_prompt_inlines_text_file_in_code_block(self, conn_text_attachment): + """KIN-094: inline-контент обёрнут в блок кода (``` ... ```).""" + ctx = build_context(conn_text_attachment, "PRJ-001", "pm", "prj") + prompt = format_prompt(ctx, "pm", "You are PM.") + assert "```" in prompt + + def test_format_prompt_inlines_md_file_by_extension(self, conn_md_attachment): + """KIN-094: .md файл определяется по расширению и вставляется inline.""" + ctx = build_context(conn_md_attachment, "PRJ-001", "pm", "prj") + prompt = format_prompt(ctx, "pm", "You are PM.") + assert "# Title" in prompt + assert "Content of readme" in prompt + + def test_format_prompt_inlines_json_file_by_mime(self, conn_json_attachment): + """KIN-094: application/json файл вставляется inline по MIME-типу.""" + ctx = build_context(conn_json_attachment, "PRJ-001", "pm", "prj") + prompt = format_prompt(ctx, "pm", "You are PM.") + assert '"key": "value"' in prompt + + # ------------------------------------------------------------------ + # NOT inlined: binary and large files + # ------------------------------------------------------------------ + + def test_format_prompt_does_not_inline_image_file(self, conn_image_attachment): + """KIN-094: бинарный PNG файл НЕ вставляется inline.""" + ctx = build_context(conn_image_attachment, "PRJ-001", "pm", "prj") + prompt = format_prompt(ctx, "pm", "You are PM.") + # File is listed in ## Attachments section but no ``` block with binary content + assert "screen.png" in prompt # listed + assert "image/png" in prompt + # Should not contain raw binary or ``` code block for the PNG + # We verify the file content (PNG header) is NOT inlined + assert "\x89PNG" not in prompt + + def test_format_prompt_does_not_inline_large_text_file(self, conn_large_text_attachment): + """KIN-094: текстовый файл > 32 KB НЕ вставляется inline.""" + ctx = build_context(conn_large_text_attachment, "PRJ-001", "pm", "prj") + prompt = format_prompt(ctx, "pm", "You are PM.") + assert "big.txt" in prompt # listed + # Content should NOT be inlined (32KB+1 of 'x' chars) + assert "x" * 100 not in prompt + + # ------------------------------------------------------------------ + # Resilience: missing file on disk + # ------------------------------------------------------------------ + + def test_format_prompt_handles_missing_file_gracefully(self, tmp_path): + """KIN-094: если файл отсутствует на диске, format_prompt не падает.""" + c = init_db(":memory:") + models.create_project(c, "prj", "Prj", str(tmp_path)) + models.create_task(c, "PRJ-001", "prj", "Task") + # Register attachment pointing to non-existent file + models.create_attachment( + c, "PRJ-001", "missing.txt", + str(tmp_path / "missing.txt"), + "text/plain", 100, + ) + ctx = build_context(c, "PRJ-001", "pm", "prj") + # Should not raise — exception is caught silently + prompt = format_prompt(ctx, "pm", "You are PM.") + assert "missing.txt" in prompt # still listed + c.close() + + # ------------------------------------------------------------------ + # PM pipeline: attachments available in brief context + # ------------------------------------------------------------------ + + def test_pm_context_includes_attachment_paths_for_pipeline(self, conn_text_attachment): + """KIN-094: PM-агент получает пути к вложениям в контексте для старта pipeline.""" + ctx = build_context(conn_text_attachment, "PRJ-001", "pm", "prj") + assert len(ctx["attachments"]) == 1 + att = ctx["attachments"][0] + assert att["filename"] == "spec.txt" + assert att["mime_type"] == "text/plain" + assert "path" in att diff --git a/tests/test_kin_091_regression.py b/tests/test_kin_091_regression.py new file mode 100644 index 0000000..6e68a5b --- /dev/null +++ b/tests/test_kin_091_regression.py @@ -0,0 +1,551 @@ +""" +Regression tests for KIN-091: +(1) Revise button — feedback loop, revise_count, target_role, max limit +(2) Auto-test before review — _run_project_tests, fix loop, block on exhaustion +(3) Spec-driven workflow — route exists and has correct steps in specialists.yaml +(4) Git worktrees — create/merge/cleanup/ensure_gitignore with mocked subprocess +(5) Auto-trigger pipeline — task with label 'auto' triggers pipeline on creation +""" + +import json +import subprocess +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock, call + +import web.api as api_module + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def client(tmp_path): + db_path = tmp_path / "test.db" + api_module.DB_PATH = db_path + from web.api import app + from fastapi.testclient import TestClient + c = TestClient(app) + c.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/tmp/p1"}) + c.post("/api/tasks", json={"project_id": "p1", "title": "Fix bug"}) + return c + + +@pytest.fixture +def conn(): + from core.db import init_db + from core import models + c = init_db(":memory:") + models.create_project(c, "vdol", "ВДОЛЬ", "~/projects/vdolipoperek", + tech_stack=["vue3"]) + models.create_task(c, "VDOL-001", "vdol", "Fix bug", + brief={"route_type": "debug"}) + yield c + c.close() + + +# --------------------------------------------------------------------------- +# (1) Revise button — revise_count, target_role, max limit +# --------------------------------------------------------------------------- + +class TestReviseEndpoint: + def test_revise_increments_revise_count(self, client): + """revise_count начинается с 0 и увеличивается на 1 при каждом вызове.""" + r = client.post("/api/tasks/P1-001/revise", json={"comment": "ещё раз"}) + assert r.status_code == 200 + assert r.json()["revise_count"] == 1 + + r = client.post("/api/tasks/P1-001/revise", json={"comment": "и ещё"}) + assert r.status_code == 200 + assert r.json()["revise_count"] == 2 + + def test_revise_stores_target_role(self, client): + """target_role сохраняется в задаче в БД.""" + from core.db import init_db + from core import models + r = client.post("/api/tasks/P1-001/revise", json={ + "comment": "доработай бэкенд", + "target_role": "backend_dev", + }) + assert r.status_code == 200 + + conn = init_db(api_module.DB_PATH) + row = conn.execute( + "SELECT revise_target_role FROM tasks WHERE id = 'P1-001'" + ).fetchone() + conn.close() + assert row["revise_target_role"] == "backend_dev" + + def test_revise_target_role_builds_short_steps(self, client): + """Если передан target_role, pipeline_steps = [target_role, reviewer].""" + r = client.post("/api/tasks/P1-001/revise", json={ + "comment": "фикс", + "target_role": "frontend_dev", + }) + assert r.status_code == 200 + steps = r.json()["pipeline_steps"] + roles = [s["role"] for s in steps] + assert roles == ["frontend_dev", "reviewer"] + + def test_revise_max_count_exceeded_returns_400(self, client): + """После 5 ревизий следующий вызов возвращает 400.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.update_task(conn, "P1-001", revise_count=5) + conn.close() + + r = client.post("/api/tasks/P1-001/revise", json={"comment": "6-й"}) + assert r.status_code == 400 + assert "Max revisions" in r.json()["detail"] + + def test_revise_sets_status_in_progress(self, client): + """После /revise задача переходит в статус in_progress.""" + r = client.post("/api/tasks/P1-001/revise", json={"comment": "исправь"}) + assert r.status_code == 200 + assert r.json()["status"] == "in_progress" + + def test_revise_only_visible_for_review_done_tasks(self, client): + """Задача со статусом 'review' возвращает 200, а не 404.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.update_task(conn, "P1-001", status="review") + conn.close() + + r = client.post("/api/tasks/P1-001/revise", json={"comment": "review→revise"}) + assert r.status_code == 200 + + def test_revise_done_task_allowed(self, client): + """Задача со статусом 'done' тоже может быть ревизована.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.update_task(conn, "P1-001", status="done") + conn.close() + + r = client.post("/api/tasks/P1-001/revise", json={"comment": "done→revise"}) + assert r.status_code == 200 + assert r.json()["status"] == "in_progress" + + +# --------------------------------------------------------------------------- +# (2) Auto-test before review — _run_project_tests, fix loop, block +# --------------------------------------------------------------------------- + +class TestRunProjectTests: + def test_returns_success_when_make_exits_0(self): + """_run_project_tests возвращает success=True при returncode=0.""" + from agents.runner import _run_project_tests + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "All tests passed." + mock_result.stderr = "" + with patch("agents.runner.subprocess.run", return_value=mock_result): + result = _run_project_tests("/fake/path") + assert result["success"] is True + assert "All tests passed." in result["output"] + + def test_returns_failure_when_make_exits_nonzero(self): + """_run_project_tests возвращает success=False при returncode!=0.""" + from agents.runner import _run_project_tests + mock_result = MagicMock() + mock_result.returncode = 2 + mock_result.stdout = "" + mock_result.stderr = "FAILED 3 tests" + with patch("agents.runner.subprocess.run", return_value=mock_result): + result = _run_project_tests("/fake/path") + assert result["success"] is False + assert "FAILED" in result["output"] + + def test_handles_make_not_found(self): + """_run_project_tests возвращает success=False если make не найден.""" + from agents.runner import _run_project_tests + with patch("agents.runner.subprocess.run", side_effect=FileNotFoundError): + result = _run_project_tests("/fake/path") + assert result["success"] is False + assert result["returncode"] == 127 + + def test_handles_timeout(self): + """_run_project_tests возвращает success=False при таймауте.""" + from agents.runner import _run_project_tests + with patch("agents.runner.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="make", timeout=120)): + result = _run_project_tests("/fake/path", timeout=120) + assert result["success"] is False + assert result["returncode"] == 124 + + +def _mock_success(output="done"): + m = MagicMock() + m.stdout = json.dumps({"result": output}) + m.stderr = "" + m.returncode = 0 + return m + + +def _mock_failure(msg="error"): + m = MagicMock() + m.stdout = "" + m.stderr = msg + m.returncode = 1 + return m + + +class TestAutoTestInPipeline: + """Pipeline с auto_test_enabled: тесты запускаются автоматически после dev-шага.""" + + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_project_tests") + @patch("agents.runner.subprocess.run") + def test_auto_test_passes_pipeline_continues( + self, mock_run, mock_tests, mock_autocommit, conn + ): + """Если авто-тест проходит — pipeline завершается успешно.""" + from agents.runner import run_pipeline + from core import models + mock_run.return_value = _mock_success() + mock_tests.return_value = {"success": True, "output": "OK", "returncode": 0} + models.update_project(conn, "vdol", auto_test_enabled=True) + + steps = [{"role": "backend_dev", "brief": "implement"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is True + mock_tests.assert_called_once() + + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_project_tests") + @patch("agents.runner.subprocess.run") + def test_auto_test_disabled_not_called( + self, mock_run, mock_tests, mock_autocommit, conn + ): + """Если auto_test_enabled=False — make test не вызывается.""" + from agents.runner import run_pipeline + from core import models + mock_run.return_value = _mock_success() + # auto_test_enabled по умолчанию 0 + + steps = [{"role": "backend_dev", "brief": "implement"}] + run_pipeline(conn, "VDOL-001", steps) + + mock_tests.assert_not_called() + + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_project_tests") + @patch("agents.runner.subprocess.run") + def test_auto_test_fail_triggers_fix_loop( + self, mock_run, mock_tests, mock_autocommit, conn + ): + """Если авто-тест падает — запускается fixer агент и тесты перезапускаются.""" + from agents.runner import run_pipeline + from core import models + import os + mock_run.return_value = _mock_success() + # First test call fails, second passes + mock_tests.side_effect = [ + {"success": False, "output": "FAILED: test_foo", "returncode": 1}, + {"success": True, "output": "OK", "returncode": 0}, + ] + models.update_project(conn, "vdol", auto_test_enabled=True) + + with patch.dict(os.environ, {"KIN_AUTO_TEST_MAX_ATTEMPTS": "3"}): + steps = [{"role": "backend_dev", "brief": "implement"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is True + # _run_project_tests called twice: initial check + after fix + assert mock_tests.call_count == 2 + # subprocess.run called at least twice: backend_dev + fixer backend_dev + assert mock_run.call_count >= 2 + + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_project_tests") + @patch("agents.runner.subprocess.run") + def test_auto_test_exhausted_blocks_task( + self, mock_run, mock_tests, mock_autocommit, conn + ): + """Если авто-тест падает max_attempts раз — задача блокируется.""" + from agents.runner import run_pipeline + from core import models + import os + + mock_run.return_value = _mock_success() + # Тест всегда падает + mock_tests.return_value = {"success": False, "output": "FAILED", "returncode": 1} + models.update_project(conn, "vdol", auto_test_enabled=True) + + with patch.dict(os.environ, {"KIN_AUTO_TEST_MAX_ATTEMPTS": "2"}): + steps = [{"role": "backend_dev", "brief": "implement"}] + result = run_pipeline(conn, "VDOL-001", steps) + + assert result["success"] is False + task = models.get_task(conn, "VDOL-001") + assert task["status"] == "blocked" + assert "Auto-test" in (task.get("blocked_reason") or "") + + @patch("agents.runner._run_autocommit") + @patch("agents.runner._run_project_tests") + @patch("agents.runner.subprocess.run") + def test_auto_test_not_triggered_for_non_dev_roles( + self, mock_run, mock_tests, mock_autocommit, conn + ): + """auto_test запускается только для backend_dev/frontend_dev, не для debugger.""" + from agents.runner import run_pipeline + from core import models + mock_run.return_value = _mock_success() + models.update_project(conn, "vdol", auto_test_enabled=True) + + steps = [{"role": "debugger", "brief": "find"}] + run_pipeline(conn, "VDOL-001", steps) + + mock_tests.assert_not_called() + + +# --------------------------------------------------------------------------- +# (3) Spec-driven workflow route +# --------------------------------------------------------------------------- + +class TestSpecDrivenRoute: + def _load_specialists(self): + import yaml + spec_path = Path(__file__).parent.parent / "agents" / "specialists.yaml" + with open(spec_path) as f: + return yaml.safe_load(f) + + def test_spec_driven_route_exists(self): + """Маршрут spec_driven должен быть объявлен в specialists.yaml.""" + data = self._load_specialists() + assert "spec_driven" in data.get("routes", {}) + + def test_spec_driven_route_steps_order(self): + """spec_driven route: шаги [constitution, spec, architect, task_decomposer].""" + data = self._load_specialists() + steps = data["routes"]["spec_driven"]["steps"] + assert steps == ["constitution", "spec", "architect", "task_decomposer"] + + def test_spec_driven_all_roles_exist(self): + """Все роли в spec_driven route должны быть объявлены в specialists.""" + data = self._load_specialists() + specialists = data.get("specialists", {}) + for role in data["routes"]["spec_driven"]["steps"]: + assert role in specialists, f"Role '{role}' missing from specialists" + + def test_constitution_role_has_output_schema(self): + """constitution должен иметь output_schema (principles, constraints, goals).""" + data = self._load_specialists() + schema = data["specialists"]["constitution"].get("output_schema", {}) + assert "principles" in schema + assert "constraints" in schema + assert "goals" in schema + + def test_spec_role_has_output_schema(self): + """spec должен иметь output_schema (overview, features, api_contracts).""" + data = self._load_specialists() + schema = data["specialists"]["spec"].get("output_schema", {}) + assert "overview" in schema + assert "features" in schema + assert "api_contracts" in schema + + +# --------------------------------------------------------------------------- +# (4) Git worktrees — create / merge / cleanup / ensure_gitignore +# --------------------------------------------------------------------------- + +class TestCreateWorktree: + def test_create_worktree_success(self, tmp_path): + """create_worktree возвращает путь при успешном git worktree add.""" + from core.worktree import create_worktree + mock_r = MagicMock() + mock_r.returncode = 0 + mock_r.stderr = "" + with patch("core.worktree.subprocess.run", return_value=mock_r): + path = create_worktree(str(tmp_path), "TASK-001", "backend_dev") + assert path is not None + assert "TASK-001-backend_dev" in path + + def test_create_worktree_git_failure_returns_none(self, tmp_path): + """create_worktree возвращает None если git worktree add провалился.""" + from core.worktree import create_worktree + mock_r = MagicMock() + mock_r.returncode = 128 + mock_r.stderr = "fatal: branch already exists" + with patch("core.worktree.subprocess.run", return_value=mock_r): + path = create_worktree(str(tmp_path), "TASK-001", "backend_dev") + assert path is None + + def test_create_worktree_exception_returns_none(self, tmp_path): + """create_worktree возвращает None при неожиданном исключении (не поднимает).""" + from core.worktree import create_worktree + with patch("core.worktree.subprocess.run", side_effect=OSError("no git")): + path = create_worktree(str(tmp_path), "TASK-001", "backend_dev") + assert path is None + + def test_create_worktree_branch_name_sanitized(self, tmp_path): + """Слэши и пробелы в имени шага заменяются на _.""" + from core.worktree import create_worktree + mock_r = MagicMock() + mock_r.returncode = 0 + mock_r.stderr = "" + calls_made = [] + def capture(*args, **kwargs): + calls_made.append(args[0]) + return mock_r + with patch("core.worktree.subprocess.run", side_effect=capture): + create_worktree(str(tmp_path), "TASK-001", "step/with spaces") + assert calls_made + cmd = calls_made[0] + branch = cmd[cmd.index("-b") + 1] + assert "/" not in branch + assert " " not in branch + + +class TestMergeWorktree: + def test_merge_success_returns_merged_files(self, tmp_path): + """merge_worktree возвращает success=True и список файлов при успешном merge.""" + from core.worktree import merge_worktree + worktree = str(tmp_path / "TASK-001-backend_dev") + + merge_ok = MagicMock(returncode=0, stdout="", stderr="") + diff_ok = MagicMock(returncode=0, stdout="src/api.py\nsrc/models.py\n", stderr="") + + with patch("core.worktree.subprocess.run", side_effect=[merge_ok, diff_ok]): + result = merge_worktree(worktree, str(tmp_path)) + + assert result["success"] is True + assert "src/api.py" in result["merged_files"] + assert result["conflicts"] == [] + + def test_merge_conflict_returns_conflict_list(self, tmp_path): + """merge_worktree возвращает success=False и список конфликтных файлов.""" + from core.worktree import merge_worktree + worktree = str(tmp_path / "TASK-001-backend_dev") + + merge_fail = MagicMock(returncode=1, stdout="", stderr="CONFLICT") + conflict_files = MagicMock(returncode=0, stdout="src/models.py\n", stderr="") + abort = MagicMock(returncode=0) + + with patch("core.worktree.subprocess.run", + side_effect=[merge_fail, conflict_files, abort]): + result = merge_worktree(worktree, str(tmp_path)) + + assert result["success"] is False + assert "src/models.py" in result["conflicts"] + + def test_merge_exception_returns_success_false(self, tmp_path): + """merge_worktree никогда не поднимает исключение.""" + from core.worktree import merge_worktree + with patch("core.worktree.subprocess.run", side_effect=OSError("git died")): + result = merge_worktree("/fake/wt", str(tmp_path)) + assert result["success"] is False + assert "error" in result + + +class TestCleanupWorktree: + def test_cleanup_calls_worktree_remove_and_branch_delete(self, tmp_path): + """cleanup_worktree вызывает git worktree remove и git branch -D.""" + from core.worktree import cleanup_worktree + calls = [] + def capture(*args, **kwargs): + calls.append(args[0]) + return MagicMock(returncode=0) + with patch("core.worktree.subprocess.run", side_effect=capture): + cleanup_worktree("/fake/path/TASK-branch", str(tmp_path)) + assert len(calls) == 2 + # первый: worktree remove + assert "worktree" in calls[0] + assert "remove" in calls[0] + # второй: branch -D + assert "branch" in calls[1] + assert "-D" in calls[1] + + def test_cleanup_never_raises(self, tmp_path): + """cleanup_worktree не поднимает исключение при ошибке.""" + from core.worktree import cleanup_worktree + with patch("core.worktree.subprocess.run", side_effect=OSError("crashed")): + cleanup_worktree("/fake/wt", str(tmp_path)) # должно пройти тихо + + +class TestEnsureGitignore: + def test_adds_entry_to_existing_gitignore(self, tmp_path): + """ensure_gitignore добавляет .kin_worktrees/ в существующий .gitignore.""" + from core.worktree import ensure_gitignore + gi = tmp_path / ".gitignore" + gi.write_text("*.pyc\n__pycache__/\n") + ensure_gitignore(str(tmp_path)) + assert ".kin_worktrees/" in gi.read_text() + + def test_creates_gitignore_if_missing(self, tmp_path): + """ensure_gitignore создаёт .gitignore если его нет.""" + from core.worktree import ensure_gitignore + ensure_gitignore(str(tmp_path)) + gi = tmp_path / ".gitignore" + assert gi.exists() + assert ".kin_worktrees/" in gi.read_text() + + def test_skips_if_entry_already_present(self, tmp_path): + """ensure_gitignore не дублирует запись.""" + from core.worktree import ensure_gitignore + gi = tmp_path / ".gitignore" + gi.write_text(".kin_worktrees/\n") + ensure_gitignore(str(tmp_path)) + content = gi.read_text() + assert content.count(".kin_worktrees/") == 1 + + def test_never_raises_on_permission_error(self, tmp_path): + """ensure_gitignore не поднимает исключение при ошибке записи.""" + from core.worktree import ensure_gitignore + with patch("core.worktree.Path.open", side_effect=PermissionError): + ensure_gitignore(str(tmp_path)) # должно пройти тихо + + +# --------------------------------------------------------------------------- +# (5) Auto-trigger pipeline — label 'auto' +# --------------------------------------------------------------------------- + +class TestAutoTrigger: + def test_task_with_auto_label_triggers_pipeline(self, client): + """Создание задачи с label 'auto' запускает pipeline в фоне.""" + with patch("web.api._launch_pipeline_subprocess") as mock_launch: + r = client.post("/api/tasks", json={ + "project_id": "p1", + "title": "Auto task", + "labels": ["auto"], + }) + assert r.status_code == 200 + mock_launch.assert_called_once() + called_task_id = mock_launch.call_args[0][0] + assert called_task_id.startswith("P1-") + + def test_task_without_auto_label_does_not_trigger(self, client): + """Создание задачи без label 'auto' НЕ запускает pipeline.""" + with patch("web.api._launch_pipeline_subprocess") as mock_launch: + r = client.post("/api/tasks", json={ + "project_id": "p1", + "title": "Manual task", + "labels": ["feature"], + }) + assert r.status_code == 200 + mock_launch.assert_not_called() + + def test_task_without_labels_does_not_trigger(self, client): + """Создание задачи без labels вообще НЕ запускает pipeline.""" + with patch("web.api._launch_pipeline_subprocess") as mock_launch: + r = client.post("/api/tasks", json={ + "project_id": "p1", + "title": "Plain task", + }) + assert r.status_code == 200 + mock_launch.assert_not_called() + + def test_task_with_auto_among_multiple_labels_triggers(self, client): + """Задача с несколькими метками включая 'auto' запускает pipeline.""" + with patch("web.api._launch_pipeline_subprocess") as mock_launch: + r = client.post("/api/tasks", json={ + "project_id": "p1", + "title": "Multi-label auto task", + "labels": ["feature", "auto", "backend"], + }) + assert r.status_code == 200 + mock_launch.assert_called_once() diff --git a/web/api.py b/web/api.py index 79218b1..62382bb 100644 --- a/web/api.py +++ b/web/api.py @@ -99,6 +99,32 @@ def get_conn(): return init_db(DB_PATH) +def _launch_pipeline_subprocess(task_id: str) -> None: + """Spawn `cli.main run {task_id}` in a detached background subprocess. + + Used by auto-trigger (label 'auto') and revise endpoint. + Never raises — subprocess errors are logged only. + """ + import os + kin_root = Path(__file__).parent.parent + cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), "run", task_id] + cmd.append("--allow-write") + env = os.environ.copy() + env["KIN_NONINTERACTIVE"] = "1" + try: + proc = subprocess.Popen( + cmd, + cwd=str(kin_root), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env=env, + ) + _logger.info("Auto-triggered pipeline for %s, pid=%d", task_id, proc.pid) + except Exception as exc: + _logger.warning("Failed to launch pipeline for %s: %s", task_id, exc) + + # --------------------------------------------------------------------------- # Projects # --------------------------------------------------------------------------- @@ -193,6 +219,7 @@ class ProjectCreate(BaseModel): class ProjectPatch(BaseModel): execution_mode: str | None = None autocommit_enabled: bool | None = None + auto_test_enabled: bool | None = None obsidian_vault_path: str | None = None deploy_command: str | None = None project_type: str | None = None @@ -206,6 +233,7 @@ class ProjectPatch(BaseModel): def patch_project(project_id: str, body: ProjectPatch): has_any = any([ body.execution_mode, body.autocommit_enabled is not None, + body.auto_test_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, @@ -227,6 +255,8 @@ def patch_project(project_id: str, body: ProjectPatch): fields["execution_mode"] = body.execution_mode if body.autocommit_enabled is not None: fields["autocommit_enabled"] = int(body.autocommit_enabled) + if body.auto_test_enabled is not None: + fields["auto_test_enabled"] = int(body.auto_test_enabled) if body.obsidian_vault_path is not None: fields["obsidian_vault_path"] = body.obsidian_vault_path if body.deploy_command is not None: @@ -527,6 +557,7 @@ class TaskCreate(BaseModel): route_type: str | None = None category: str | None = None acceptance_criteria: str | None = None + labels: list[str] | None = None @app.post("/api/tasks") @@ -546,8 +577,14 @@ def create_task(body: TaskCreate): brief = {"route_type": body.route_type} if body.route_type else None t = models.create_task(conn, task_id, body.project_id, body.title, priority=body.priority, brief=brief, category=category, - acceptance_criteria=body.acceptance_criteria) + acceptance_criteria=body.acceptance_criteria, + labels=body.labels) conn.close() + + # Auto-trigger: if task has 'auto' label, launch pipeline in background + if body.labels and "auto" in body.labels: + _launch_pipeline_subprocess(task_id) + return t @@ -763,21 +800,66 @@ def reject_task(task_id: str, body: TaskReject): return {"status": "pending", "reason": body.reason} +_MAX_REVISE_COUNT = 5 + + class TaskRevise(BaseModel): comment: str + steps: list[dict] | None = None # override pipeline steps (optional) + target_role: str | None = None # if set, re-run only [target_role, reviewer] instead of full pipeline @app.post("/api/tasks/{task_id}/revise") def revise_task(task_id: str, body: TaskRevise): - """Revise a task: return to in_progress with director's comment for the agent.""" + """Revise a task: update comment, increment revise_count, and re-run pipeline.""" + if not body.comment.strip(): + raise HTTPException(400, "comment must not be empty") + conn = get_conn() t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") - models.update_task(conn, task_id, status="in_progress", revise_comment=body.comment) + + revise_count = (t.get("revise_count") or 0) + 1 + if revise_count > _MAX_REVISE_COUNT: + conn.close() + raise HTTPException(400, f"Max revisions ({_MAX_REVISE_COUNT}) reached for this task") + + models.update_task( + conn, task_id, + status="in_progress", + revise_comment=body.comment, + revise_count=revise_count, + revise_target_role=body.target_role, + ) + + # Resolve steps: explicit > target_role shortcut > last pipeline steps + steps = body.steps + if not steps: + if body.target_role: + steps = [{"role": body.target_role}, {"role": "reviewer"}] + else: + row = conn.execute( + "SELECT steps FROM pipelines WHERE task_id = ? ORDER BY id DESC LIMIT 1", + (task_id,), + ).fetchone() + if row: + import json as _json + raw = row["steps"] + steps = _json.loads(raw) if isinstance(raw, str) else raw + conn.close() - return {"status": "in_progress", "comment": body.comment} + + # Launch pipeline in background subprocess + _launch_pipeline_subprocess(task_id) + + return { + "status": "in_progress", + "comment": body.comment, + "revise_count": revise_count, + "pipeline_steps": steps, + } @app.get("/api/tasks/{task_id}/running") diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index b775b79..69cae1d 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -112,6 +112,7 @@ export interface Task { dangerously_skipped: number | null category: string | null acceptance_criteria: string | null + feedback?: string | null created_at: string updated_at: string } diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 43ab557..db59b10 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -33,6 +33,34 @@ const startPhaseSaving = ref(false) const approvePhaseSaving = ref(false) let phasePollTimer: ReturnType | null = null +// Task Revise +const showTaskReviseModal = ref(false) +const taskReviseTaskId = ref(null) +const taskReviseComment = ref('') +const taskReviseError = ref('') +const taskReviseSaving = ref(false) + +function openTaskRevise(taskId: string) { + taskReviseTaskId.value = taskId + taskReviseComment.value = '' + taskReviseError.value = '' + showTaskReviseModal.value = true +} + +async function submitTaskRevise() { + if (!taskReviseComment.value.trim()) { taskReviseError.value = 'Комментарий обязателен'; return } + taskReviseSaving.value = true + try { + await api.reviseTask(taskReviseTaskId.value!, taskReviseComment.value) + showTaskReviseModal.value = false + await load() + } catch (e: any) { + taskReviseError.value = e.message + } finally { + taskReviseSaving.value = false + } +} + function checkAndPollPhases() { const hasRunning = phases.value.some(ph => ph.task?.status === 'in_progress') if (hasRunning && !phasePollTimer) { @@ -143,7 +171,7 @@ function phaseStatusColor(s: string) { } // Filters -const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'cancelled'] +const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'revising', 'cancelled'] function initStatusFilter(): string[] { const q = route.query.status as string @@ -333,6 +361,21 @@ const CATEGORY_COLORS: Record = { const showAddTask = ref(false) const taskForm = ref({ title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' }) const taskFormError = ref('') +const pendingFiles = ref([]) +const fileInputRef = ref(null) + +function onFileSelect(e: Event) { + const input = e.target as HTMLInputElement + if (input.files) { + pendingFiles.value.push(...Array.from(input.files)) + input.value = '' + } +} + +function closeAddTaskModal() { + showAddTask.value = false + pendingFiles.value = [] +} // Add decision modal const showAddDecision = ref(false) @@ -421,6 +464,7 @@ function taskStatusColor(s: string) { const m: Record = { pending: 'gray', in_progress: 'blue', review: 'purple', done: 'green', blocked: 'red', decomposed: 'yellow', cancelled: 'gray', + revising: 'orange', } return m[s] || 'gray' } @@ -449,7 +493,7 @@ const decTypes = computed(() => { async function addTask() { taskFormError.value = '' try { - await api.createTask({ + const task = await api.createTask({ project_id: props.id, title: taskForm.value.title, priority: taskForm.value.priority, @@ -457,6 +501,20 @@ async function addTask() { category: taskForm.value.category || undefined, acceptance_criteria: taskForm.value.acceptance_criteria || undefined, }) + if (pendingFiles.value.length > 0) { + const failedFiles: string[] = [] + for (const file of pendingFiles.value) { + try { + await api.uploadAttachment(task.id, file) + } catch { + failedFiles.push(file.name) + } + } + pendingFiles.value = [] + if (failedFiles.length > 0) { + console.warn('Failed to upload attachments:', failedFiles) + } + } showAddTask.value = false taskForm.value = { title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' } await load() @@ -798,6 +856,12 @@ async function addDecision() { + @@ -1092,7 +1156,7 @@ async function addDecision() { - +
@@ -1117,6 +1181,25 @@ async function addDecision() { placeholder="Что должно быть на выходе? Какой результат считается успешным?" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"> +
+ +
+ + {{ pendingFiles.length }} файл(ов) +
+ +
    +
  • + {{ file.name }} + +
  • +
+

{{ taskFormError }}

+ +
+