kin: KIN-048 Post-pipeline hook: автокоммит после успешного завершения задачи. git add -A && git commit -m 'kin: TASK_ID TITLE'. Срабатывает автоматически как rebuild-frontend.
This commit is contained in:
parent
8a6f280cbd
commit
ae21e48b65
13 changed files with 1554 additions and 65 deletions
41
agents/prompts/learner.md
Normal file
41
agents/prompts/learner.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
You are a learning extractor for the Kin multi-agent orchestrator.
|
||||
|
||||
Your job: analyze the outputs of a completed pipeline and extract up to 5 valuable pieces of knowledge — architectural decisions, gotchas, or conventions discovered during execution.
|
||||
|
||||
## Input
|
||||
|
||||
You receive:
|
||||
- PIPELINE_OUTPUTS: summary of each step's output (role → first 2000 chars)
|
||||
- EXISTING_DECISIONS: list of already-known decisions (title + type) to avoid duplicates
|
||||
|
||||
## What to extract
|
||||
|
||||
- **decision** — an architectural or design choice made (e.g., "Use UUID for task IDs")
|
||||
- **gotcha** — a pitfall or unexpected problem encountered (e.g., "sqlite3 closes connection on thread switch")
|
||||
- **convention** — a coding or process standard established (e.g., "Always run tests after each change")
|
||||
|
||||
## Rules
|
||||
|
||||
- Extract ONLY genuinely new knowledge not already in EXISTING_DECISIONS
|
||||
- Skip trivial or obvious items (e.g., "write clean code")
|
||||
- Skip task-specific results that won't generalize (e.g., "fixed bug in useSearch.ts line 42")
|
||||
- Each decision must be actionable and reusable across future tasks
|
||||
- Extract at most 5 decisions total; fewer is better than low-quality ones
|
||||
- If nothing valuable found, return empty list
|
||||
|
||||
## Output format
|
||||
|
||||
Return ONLY valid JSON (no markdown, no explanation):
|
||||
|
||||
```json
|
||||
{
|
||||
"decisions": [
|
||||
{
|
||||
"type": "decision",
|
||||
"title": "Short memorable title",
|
||||
"description": "Clear explanation of what was decided and why",
|
||||
"tags": ["optional", "tags"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
|
@ -30,6 +30,16 @@ You receive:
|
|||
- Don't assign specialists who aren't needed.
|
||||
- If a task is blocked or unclear, say so — don't guess.
|
||||
|
||||
## Completion mode selection
|
||||
|
||||
Set `completion_mode` based on the following rules (in priority order):
|
||||
|
||||
1. If `project.execution_mode` is set — use it as the default.
|
||||
2. Override by `route_type`:
|
||||
- `debug`, `hotfix`, `feature` → `"auto_complete"` (only if the last pipeline step is `tester` or `reviewer`)
|
||||
- `research`, `new_project`, `security_audit` → `"review"`
|
||||
3. Fallback: `"review"`
|
||||
|
||||
## Output format
|
||||
|
||||
Return ONLY valid JSON (no markdown, no explanation):
|
||||
|
|
@ -37,6 +47,7 @@ Return ONLY valid JSON (no markdown, no explanation):
|
|||
```json
|
||||
{
|
||||
"analysis": "Brief analysis of what needs to be done",
|
||||
"completion_mode": "auto_complete",
|
||||
"pipeline": [
|
||||
{
|
||||
"role": "debugger",
|
||||
|
|
|
|||
252
agents/runner.py
252
agents/runner.py
|
|
@ -4,7 +4,9 @@ Each agent = separate process with isolated context.
|
|||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
|
|
@ -13,6 +15,50 @@ from typing import Any
|
|||
|
||||
import re
|
||||
|
||||
_logger = logging.getLogger("kin.runner")
|
||||
|
||||
|
||||
# Extra PATH entries to inject when searching for claude CLI.
|
||||
# launchctl daemons start with a stripped PATH that may omit these.
|
||||
_EXTRA_PATH_DIRS = [
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/sbin",
|
||||
"/usr/local/bin",
|
||||
"/usr/local/sbin",
|
||||
]
|
||||
|
||||
|
||||
def _build_claude_env() -> dict:
|
||||
"""Return an env dict with an extended PATH that includes common CLI tool locations.
|
||||
|
||||
Merges _EXTRA_PATH_DIRS with the current process PATH, deduplicating entries.
|
||||
Also resolves ~/.nvm/versions/node/*/bin globs that launchctl may not expand.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
existing = env.get("PATH", "").split(":")
|
||||
|
||||
extra = list(_EXTRA_PATH_DIRS)
|
||||
|
||||
# Expand nvm node bin dirs dynamically
|
||||
nvm_root = Path.home() / ".nvm" / "versions" / "node"
|
||||
if nvm_root.is_dir():
|
||||
for node_ver in sorted(nvm_root.iterdir(), reverse=True):
|
||||
bin_dir = node_ver / "bin"
|
||||
if bin_dir.is_dir():
|
||||
extra.append(str(bin_dir))
|
||||
|
||||
seen = set(existing)
|
||||
new_dirs = [d for d in extra if d and d not in seen]
|
||||
env["PATH"] = ":".join(new_dirs + existing)
|
||||
return env
|
||||
|
||||
|
||||
def _resolve_claude_cmd() -> str:
|
||||
"""Return the full path to the claude CLI, or 'claude' as fallback."""
|
||||
extended_env = _build_claude_env()
|
||||
found = shutil.which("claude", path=extended_env["PATH"])
|
||||
return found or "claude"
|
||||
|
||||
from core import models
|
||||
from core.context_builder import build_context, format_prompt
|
||||
from core.hooks import run_hooks
|
||||
|
|
@ -116,10 +162,12 @@ def _run_claude(
|
|||
working_dir: str | None = None,
|
||||
allow_write: bool = False,
|
||||
noninteractive: bool = False,
|
||||
timeout: int | None = None,
|
||||
) -> dict:
|
||||
"""Execute claude CLI as subprocess. Returns dict with output, returncode, etc."""
|
||||
claude_cmd = _resolve_claude_cmd()
|
||||
cmd = [
|
||||
"claude",
|
||||
claude_cmd,
|
||||
"-p", prompt,
|
||||
"--output-format", "json",
|
||||
"--model", model,
|
||||
|
|
@ -128,7 +176,9 @@ def _run_claude(
|
|||
cmd.append("--dangerously-skip-permissions")
|
||||
|
||||
is_noninteractive = noninteractive or os.environ.get("KIN_NONINTERACTIVE") == "1"
|
||||
timeout = 300 if is_noninteractive else 600
|
||||
if timeout is None:
|
||||
timeout = int(os.environ.get("KIN_AGENT_TIMEOUT") or 600)
|
||||
env = _build_claude_env()
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
|
|
@ -137,6 +187,7 @@ def _run_claude(
|
|||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=working_dir,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL if is_noninteractive else None,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
|
|
@ -377,6 +428,179 @@ def _is_permission_error(result: dict) -> bool:
|
|||
return any(re.search(p, text) for p in PERMISSION_PATTERNS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Autocommit: git add -A && git commit after successful pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run_autocommit(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
project_id: str,
|
||||
) -> None:
|
||||
"""Auto-commit changes after successful pipeline completion.
|
||||
|
||||
Runs: git add -A && git commit -m 'kin: {task_id} {title}'.
|
||||
Silently skips if nothing to commit (exit code 1) or project path not found.
|
||||
Never raises — autocommit errors must never block the pipeline.
|
||||
Uses stderr=subprocess.DEVNULL per decision #30.
|
||||
"""
|
||||
task = models.get_task(conn, task_id)
|
||||
project = models.get_project(conn, project_id)
|
||||
if not task or not project:
|
||||
return
|
||||
|
||||
if not project.get("autocommit_enabled"):
|
||||
return
|
||||
|
||||
project_path = Path(project["path"]).expanduser()
|
||||
if not project_path.is_dir():
|
||||
return
|
||||
|
||||
working_dir = str(project_path)
|
||||
env = _build_claude_env()
|
||||
git_cmd = shutil.which("git", path=env["PATH"]) or "git"
|
||||
|
||||
title = (task.get("title") or "").replace('"', "'").replace("\n", " ").replace("\r", "")
|
||||
commit_msg = f"kin: {task_id} {title}"
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[git_cmd, "add", "-A"],
|
||||
cwd=working_dir,
|
||||
env=env,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
result = subprocess.run(
|
||||
[git_cmd, "commit", "-m", commit_msg],
|
||||
cwd=working_dir,
|
||||
env=env,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_logger.info("Autocommit: %s", commit_msg)
|
||||
else:
|
||||
_logger.debug("Autocommit: nothing to commit for %s", task_id)
|
||||
except Exception as exc:
|
||||
_logger.warning("Autocommit failed for %s: %s", task_id, exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-learning: extract decisions from pipeline results
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_DECISION_TYPES = {"decision", "gotcha", "convention"}
|
||||
|
||||
def _run_learning_extraction(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
project_id: str,
|
||||
step_results: list[dict],
|
||||
) -> dict:
|
||||
"""Extract and save decisions from completed pipeline results.
|
||||
|
||||
Calls the learner agent with step outputs + existing decisions,
|
||||
parses the JSON response, and saves new decisions via add_decision_if_new.
|
||||
Returns a summary dict with added/skipped counts.
|
||||
"""
|
||||
learner_prompt_path = PROMPTS_DIR / "learner.md"
|
||||
if not learner_prompt_path.exists():
|
||||
return {"added": 0, "skipped": 0, "error": "learner.md not found"}
|
||||
|
||||
template = learner_prompt_path.read_text()
|
||||
|
||||
# Summarize step outputs (first 2000 chars each)
|
||||
step_summaries = {}
|
||||
for r in step_results:
|
||||
role = r.get("role", "unknown")
|
||||
output = r.get("raw_output") or r.get("output") or ""
|
||||
if isinstance(output, (dict, list)):
|
||||
output = json.dumps(output, ensure_ascii=False)
|
||||
step_summaries[role] = output[:2000]
|
||||
|
||||
# Fetch existing decisions for dedup hint
|
||||
existing = models.get_decisions(conn, project_id)
|
||||
existing_hints = [
|
||||
{"title": d["title"], "type": d["type"]}
|
||||
for d in existing
|
||||
]
|
||||
|
||||
prompt_parts = [
|
||||
template,
|
||||
"",
|
||||
"## PIPELINE_OUTPUTS",
|
||||
json.dumps(step_summaries, ensure_ascii=False, indent=2),
|
||||
"",
|
||||
"## EXISTING_DECISIONS",
|
||||
json.dumps(existing_hints, ensure_ascii=False, indent=2),
|
||||
]
|
||||
prompt = "\n".join(prompt_parts)
|
||||
|
||||
learner_timeout = int(os.environ.get("KIN_LEARNER_TIMEOUT") or 120)
|
||||
start = time.monotonic()
|
||||
result = _run_claude(prompt, model="sonnet", noninteractive=True, timeout=learner_timeout)
|
||||
duration = int(time.monotonic() - start)
|
||||
|
||||
raw_output = result.get("output", "")
|
||||
if not isinstance(raw_output, str):
|
||||
raw_output = json.dumps(raw_output, ensure_ascii=False)
|
||||
success = result["returncode"] == 0
|
||||
|
||||
# Log to agent_logs
|
||||
models.log_agent_run(
|
||||
conn,
|
||||
project_id=project_id,
|
||||
task_id=task_id,
|
||||
agent_role="learner",
|
||||
action="learn",
|
||||
input_summary=f"project={project_id}, task={task_id}, steps={len(step_results)}",
|
||||
output_summary=raw_output or None,
|
||||
tokens_used=result.get("tokens_used"),
|
||||
model="sonnet",
|
||||
cost_usd=result.get("cost_usd"),
|
||||
success=success,
|
||||
error_message=result.get("error") if not success else None,
|
||||
duration_seconds=duration,
|
||||
)
|
||||
|
||||
parsed = _try_parse_json(raw_output)
|
||||
if not isinstance(parsed, dict):
|
||||
return {"added": 0, "skipped": 0, "error": "non-JSON learner output"}
|
||||
|
||||
decisions = parsed.get("decisions", [])
|
||||
if not isinstance(decisions, list):
|
||||
return {"added": 0, "skipped": 0, "error": "invalid decisions format"}
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
for item in decisions[:5]:
|
||||
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 [],
|
||||
task_id=task_id,
|
||||
)
|
||||
if saved:
|
||||
added += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
return {"added": added, "skipped": skipped}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline executor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -485,7 +709,7 @@ def run_pipeline(
|
|||
|
||||
if not result["success"]:
|
||||
# Auto mode: retry once with allow_write on permission error
|
||||
if mode == "auto" and not allow_write and _is_permission_error(result):
|
||||
if mode == "auto_complete" and not allow_write and _is_permission_error(result):
|
||||
task_modules = models.get_modules(conn, project_id)
|
||||
try:
|
||||
run_hooks(conn, project_id, task_id,
|
||||
|
|
@ -555,8 +779,11 @@ def run_pipeline(
|
|||
|
||||
task_modules = models.get_modules(conn, project_id)
|
||||
|
||||
if mode == "auto":
|
||||
# Auto mode: skip review, approve immediately
|
||||
last_role = steps[-1].get("role", "") if steps else ""
|
||||
auto_eligible = last_role in {"tester", "reviewer"}
|
||||
|
||||
if mode == "auto_complete" and auto_eligible:
|
||||
# Auto-complete mode: last step is tester/reviewer — skip review, approve immediately
|
||||
models.update_task(conn, task_id, status="done")
|
||||
try:
|
||||
run_hooks(conn, project_id, task_id,
|
||||
|
|
@ -586,7 +813,7 @@ def run_pipeline(
|
|||
pass
|
||||
else:
|
||||
# Review mode: wait for manual approval
|
||||
models.update_task(conn, task_id, status="review")
|
||||
models.update_task(conn, task_id, status="review", execution_mode="review")
|
||||
|
||||
# Run post-pipeline hooks (failures don't affect pipeline status)
|
||||
try:
|
||||
|
|
@ -595,6 +822,19 @@ def run_pipeline(
|
|||
except Exception:
|
||||
pass # Hook errors must never block pipeline completion
|
||||
|
||||
# Auto-learning: extract decisions from pipeline results
|
||||
if results:
|
||||
try:
|
||||
_run_learning_extraction(conn, task_id, project_id, results)
|
||||
except Exception:
|
||||
pass # Learning errors must never block pipeline completion
|
||||
|
||||
# Auto-commit changes after successful pipeline
|
||||
try:
|
||||
_run_autocommit(conn, task_id, project_id)
|
||||
except Exception:
|
||||
pass # Autocommit errors must never block pipeline completion
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"steps_completed": len(steps),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue