claude-controller 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/autoloop.sh +382 -0
- package/bin/ctl +1189 -0
- package/bin/native-app.py +6 -3
- package/bin/watchdog.sh +357 -0
- package/cognitive/__init__.py +14 -0
- package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
- package/cognitive/dispatcher.py +192 -0
- package/cognitive/evaluator.py +289 -0
- package/cognitive/goal_engine.py +232 -0
- package/cognitive/learning.py +189 -0
- package/cognitive/orchestrator.py +303 -0
- package/cognitive/planner.py +207 -0
- package/cognitive/prompts/analyst.md +31 -0
- package/cognitive/prompts/coder.md +22 -0
- package/cognitive/prompts/reviewer.md +33 -0
- package/cognitive/prompts/tester.md +21 -0
- package/cognitive/prompts/writer.md +25 -0
- package/config.sh +6 -1
- package/dag/__init__.py +5 -0
- package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
- package/dag/__pycache__/graph.cpython-314.pyc +0 -0
- package/dag/graph.py +222 -0
- package/lib/jobs.sh +12 -1
- package/package.json +11 -5
- package/postinstall.sh +1 -1
- package/service/controller.sh +43 -11
- package/web/audit.py +122 -0
- package/web/checkpoint.py +80 -0
- package/web/config.py +2 -5
- package/web/handler.py +634 -473
- package/web/handler_fs.py +153 -0
- package/web/handler_goals.py +203 -0
- package/web/handler_jobs.py +372 -0
- package/web/handler_memory.py +203 -0
- package/web/handler_sessions.py +132 -0
- package/web/jobs.py +585 -13
- package/web/personas.py +419 -0
- package/web/pipeline.py +981 -0
- package/web/presets.py +506 -0
- package/web/projects.py +246 -0
- package/web/static/api.js +141 -0
- package/web/static/app.js +25 -1937
- package/web/static/attachments.js +144 -0
- package/web/static/base.css +497 -0
- package/web/static/context.js +204 -0
- package/web/static/dirs.js +246 -0
- package/web/static/form.css +763 -0
- package/web/static/goals.css +363 -0
- package/web/static/goals.js +300 -0
- package/web/static/i18n.js +625 -0
- package/web/static/index.html +215 -13
- package/web/static/{styles.css → jobs.css} +746 -1141
- package/web/static/jobs.js +1270 -0
- package/web/static/memoryview.js +117 -0
- package/web/static/personas.js +228 -0
- package/web/static/pipeline.css +338 -0
- package/web/static/pipelines.js +487 -0
- package/web/static/presets.js +244 -0
- package/web/static/send.js +135 -0
- package/web/static/settings-style.css +291 -0
- package/web/static/settings.js +81 -0
- package/web/static/stream.js +534 -0
- package/web/static/utils.js +131 -0
- package/web/webhook.py +210 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Learning Module — 자기 개선 엔진
|
|
3
|
+
태스크 실행 결과를 분석하여 패턴을 추출하고,
|
|
4
|
+
향후 계획·실행의 품질을 개선한다.
|
|
5
|
+
|
|
6
|
+
학습 영역:
|
|
7
|
+
1. 프롬프트 최적화: 성공/실패 패턴 → 프롬프트 템플릿 개선
|
|
8
|
+
2. 시간/비용 추정: 과거 데이터 기반 정확도 향상
|
|
9
|
+
3. Worker 선택: 태스크 유형별 최적 Worker 매핑
|
|
10
|
+
4. 실패 패턴: 반복되는 실패 원인 축적 → 사전 회피
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from memory.store import MemoryStore, MemoryType
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LearningModule:
|
|
23
|
+
"""태스크 실행 결과를 분석하여 시스템을 개선한다."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, memory: MemoryStore, data_dir: str):
|
|
26
|
+
self.memory = memory
|
|
27
|
+
self.data_dir = Path(data_dir)
|
|
28
|
+
self.outcomes_dir = self.data_dir / "outcomes"
|
|
29
|
+
self.outcomes_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
def record_outcome(
|
|
32
|
+
self,
|
|
33
|
+
goal_id: str,
|
|
34
|
+
objective: str,
|
|
35
|
+
achieved: bool,
|
|
36
|
+
dag: dict,
|
|
37
|
+
cost_usd: float,
|
|
38
|
+
eval_report: dict,
|
|
39
|
+
):
|
|
40
|
+
"""목표 실행 결과를 기록한다."""
|
|
41
|
+
outcome = {
|
|
42
|
+
"goal_id": goal_id,
|
|
43
|
+
"objective": objective,
|
|
44
|
+
"achieved": achieved,
|
|
45
|
+
"cost_usd": cost_usd,
|
|
46
|
+
"task_count": len(dag.get("tasks", [])),
|
|
47
|
+
"eval_report": eval_report,
|
|
48
|
+
"timestamp": time.time(),
|
|
49
|
+
"tasks_summary": self._summarize_tasks(dag),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# 결과 파일 저장
|
|
53
|
+
path = self.outcomes_dir / f"{goal_id}.json"
|
|
54
|
+
with open(path, "w") as f:
|
|
55
|
+
json.dump(outcome, f, indent=2, ensure_ascii=False)
|
|
56
|
+
|
|
57
|
+
# 실패 패턴 → Memory에 축적
|
|
58
|
+
if not achieved:
|
|
59
|
+
self._learn_from_failure(outcome)
|
|
60
|
+
|
|
61
|
+
# 성공 패턴 → Memory에 축적
|
|
62
|
+
if achieved:
|
|
63
|
+
self._learn_from_success(outcome)
|
|
64
|
+
|
|
65
|
+
def get_cost_estimate(self, task_count: int) -> dict:
|
|
66
|
+
"""과거 데이터 기반 비용/시간 추정."""
|
|
67
|
+
outcomes = self._load_recent_outcomes(limit=50)
|
|
68
|
+
if not outcomes:
|
|
69
|
+
return {
|
|
70
|
+
"estimated_cost_usd": task_count * 0.25,
|
|
71
|
+
"confidence": "low",
|
|
72
|
+
"basis": "기본 추정 (데이터 부족)",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# 태스크당 평균 비용 계산
|
|
76
|
+
total_cost = sum(o["cost_usd"] for o in outcomes)
|
|
77
|
+
total_tasks = sum(o["task_count"] for o in outcomes)
|
|
78
|
+
|
|
79
|
+
if total_tasks == 0:
|
|
80
|
+
cost_per_task = 0.25
|
|
81
|
+
else:
|
|
82
|
+
cost_per_task = total_cost / total_tasks
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"estimated_cost_usd": round(task_count * cost_per_task, 2),
|
|
86
|
+
"cost_per_task": round(cost_per_task, 3),
|
|
87
|
+
"confidence": "high" if len(outcomes) > 10 else "medium",
|
|
88
|
+
"basis": f"최근 {len(outcomes)}개 목표의 평균",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
def get_success_rate(self) -> dict:
|
|
92
|
+
"""전체 목표 성공률을 반환한다."""
|
|
93
|
+
outcomes = self._load_recent_outcomes(limit=100)
|
|
94
|
+
if not outcomes:
|
|
95
|
+
return {"rate": 0.0, "total": 0, "achieved": 0}
|
|
96
|
+
|
|
97
|
+
achieved = sum(1 for o in outcomes if o["achieved"])
|
|
98
|
+
return {
|
|
99
|
+
"rate": round(achieved / len(outcomes), 2),
|
|
100
|
+
"total": len(outcomes),
|
|
101
|
+
"achieved": achieved,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def get_failure_patterns(self, limit: int = 10) -> list[dict]:
|
|
105
|
+
"""최근 실패 패턴을 반환한다."""
|
|
106
|
+
failures = self.memory.search(
|
|
107
|
+
query="",
|
|
108
|
+
memory_type=MemoryType.FAILURE,
|
|
109
|
+
limit=limit,
|
|
110
|
+
)
|
|
111
|
+
return failures
|
|
112
|
+
|
|
113
|
+
def _learn_from_failure(self, outcome: dict):
|
|
114
|
+
"""실패로부터 패턴을 추출하여 Memory에 저장한다."""
|
|
115
|
+
failed_tasks = [
|
|
116
|
+
t for t in outcome.get("tasks_summary", [])
|
|
117
|
+
if t.get("status") == "failed"
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
if not failed_tasks:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# 실패 태스크의 Worker 유형과 실패 원인 축적
|
|
124
|
+
for task in failed_tasks:
|
|
125
|
+
self.memory.add(
|
|
126
|
+
memory_type=MemoryType.FAILURE,
|
|
127
|
+
title=f"실패: {task.get('name', 'unknown')}",
|
|
128
|
+
content=(
|
|
129
|
+
f"목표: {outcome['objective']}\n"
|
|
130
|
+
f"태스크: {task.get('name')}\n"
|
|
131
|
+
f"Worker: {task.get('worker_type')}\n"
|
|
132
|
+
f"재시도: {task.get('retries', 0)}회\n"
|
|
133
|
+
f"비용: ${task.get('cost_usd', 0)}"
|
|
134
|
+
),
|
|
135
|
+
tags=[
|
|
136
|
+
task.get("worker_type", "unknown"),
|
|
137
|
+
"failure",
|
|
138
|
+
outcome["goal_id"],
|
|
139
|
+
],
|
|
140
|
+
goal_id=outcome["goal_id"],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _learn_from_success(self, outcome: dict):
|
|
144
|
+
"""성공으로부터 패턴을 추출한다."""
|
|
145
|
+
# 효율적인 DAG 구조 기록 (비용이 평균 이하인 경우)
|
|
146
|
+
avg_cost = self._get_avg_cost()
|
|
147
|
+
if avg_cost > 0 and outcome["cost_usd"] < avg_cost * 0.7:
|
|
148
|
+
self.memory.add(
|
|
149
|
+
memory_type=MemoryType.PATTERN,
|
|
150
|
+
title=f"효율적 패턴: {outcome['objective'][:50]}",
|
|
151
|
+
content=(
|
|
152
|
+
f"목표: {outcome['objective']}\n"
|
|
153
|
+
f"태스크 수: {outcome['task_count']}\n"
|
|
154
|
+
f"비용: ${outcome['cost_usd']} (평균 ${avg_cost:.2f} 대비 절약)\n"
|
|
155
|
+
f"DAG 구조: {len(outcome.get('tasks_summary', []))} 태스크"
|
|
156
|
+
),
|
|
157
|
+
tags=["efficient", "cost_saving", outcome["goal_id"]],
|
|
158
|
+
goal_id=outcome["goal_id"],
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _summarize_tasks(self, dag: dict) -> list[dict]:
|
|
162
|
+
"""DAG의 태스크들을 요약한다."""
|
|
163
|
+
return [
|
|
164
|
+
{
|
|
165
|
+
"id": t.get("id"),
|
|
166
|
+
"name": t.get("name"),
|
|
167
|
+
"worker_type": t.get("worker_type"),
|
|
168
|
+
"status": t.get("status"),
|
|
169
|
+
"cost_usd": t.get("cost_usd", 0),
|
|
170
|
+
"retries": t.get("retries", 0),
|
|
171
|
+
}
|
|
172
|
+
for t in dag.get("tasks", [])
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
def _load_recent_outcomes(self, limit: int = 50) -> list[dict]:
|
|
176
|
+
"""최근 실행 결과를 로드한다."""
|
|
177
|
+
outcomes = []
|
|
178
|
+
paths = sorted(self.outcomes_dir.glob("goal-*.json"), reverse=True)
|
|
179
|
+
for path in paths[:limit]:
|
|
180
|
+
with open(path) as f:
|
|
181
|
+
outcomes.append(json.load(f))
|
|
182
|
+
return outcomes
|
|
183
|
+
|
|
184
|
+
def _get_avg_cost(self) -> float:
|
|
185
|
+
"""전체 평균 비용을 계산한다."""
|
|
186
|
+
outcomes = self._load_recent_outcomes(limit=50)
|
|
187
|
+
if not outcomes:
|
|
188
|
+
return 0.0
|
|
189
|
+
return sum(o["cost_usd"] for o in outcomes) / len(outcomes)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Orchestrator — 인지 루프의 두뇌
|
|
3
|
+
Goal Engine, Planner, Dispatcher, Evaluator, Memory를 조율하여
|
|
4
|
+
목표를 자율적으로 달성하는 전체 라이프사이클을 관리한다.
|
|
5
|
+
|
|
6
|
+
인지 루프:
|
|
7
|
+
Goal → Plan → Execute → Evaluate → [Learn] → Done/Retry
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from cognitive.goal_engine import GoalEngine, GoalStatus, ExecutionMode
|
|
17
|
+
from cognitive.planner import Planner
|
|
18
|
+
from cognitive.dispatcher import Dispatcher
|
|
19
|
+
from cognitive.evaluator import Evaluator
|
|
20
|
+
from cognitive.learning import LearningModule
|
|
21
|
+
from memory.store import MemoryStore, MemoryType
|
|
22
|
+
from dag.graph import TaskDAG
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("orchestrator")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Orchestrator:
|
|
28
|
+
"""인지 루프를 구동하는 최상위 조율기.
|
|
29
|
+
|
|
30
|
+
사용법:
|
|
31
|
+
orch = Orchestrator(base_dir="/path/to/controller")
|
|
32
|
+
goal = orch.set_goal("API 응답 시간 50% 단축", cwd="/project")
|
|
33
|
+
orch.run(goal["id"]) # 자율 실행
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, base_dir: str, claude_bin: str = "claude"):
|
|
37
|
+
self.base_dir = Path(base_dir)
|
|
38
|
+
self.claude_bin = claude_bin
|
|
39
|
+
|
|
40
|
+
# 핵심 컴포넌트 초기화
|
|
41
|
+
self.goal_engine = GoalEngine(str(self.base_dir / "data"))
|
|
42
|
+
self.memory = MemoryStore(str(self.base_dir / "memory"))
|
|
43
|
+
self.planner = Planner(claude_bin)
|
|
44
|
+
self.evaluator = None # cwd 설정 후 초기화
|
|
45
|
+
self.learning = LearningModule(
|
|
46
|
+
memory=self.memory,
|
|
47
|
+
data_dir=str(self.base_dir / "data" / "learning"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
self._dispatcher = None
|
|
51
|
+
|
|
52
|
+
def set_goal(
|
|
53
|
+
self,
|
|
54
|
+
objective: str,
|
|
55
|
+
cwd: str,
|
|
56
|
+
mode: str = "gate",
|
|
57
|
+
budget_usd: float = 5.0,
|
|
58
|
+
max_tasks: int = 20,
|
|
59
|
+
) -> dict:
|
|
60
|
+
"""새 목표를 설정한다.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
생성된 목표 dict
|
|
64
|
+
"""
|
|
65
|
+
exec_mode = ExecutionMode(mode)
|
|
66
|
+
context = {"cwd": cwd}
|
|
67
|
+
|
|
68
|
+
goal = self.goal_engine.create_goal(
|
|
69
|
+
objective=objective,
|
|
70
|
+
mode=exec_mode,
|
|
71
|
+
context=context,
|
|
72
|
+
budget_usd=budget_usd,
|
|
73
|
+
max_tasks=max_tasks,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
logger.info(f"Goal created: {goal['id']} — {objective}")
|
|
77
|
+
return goal
|
|
78
|
+
|
|
79
|
+
def plan(self, goal_id: str) -> dict:
|
|
80
|
+
"""목표에 대한 실행 계획(DAG)을 생성한다.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
DAG 정보가 포함된 업데이트된 목표 dict
|
|
84
|
+
"""
|
|
85
|
+
goal = self.goal_engine.get_goal(goal_id)
|
|
86
|
+
if not goal:
|
|
87
|
+
raise ValueError(f"Goal not found: {goal_id}")
|
|
88
|
+
|
|
89
|
+
cwd = goal["context"].get("cwd", ".")
|
|
90
|
+
self.goal_engine.update_status(goal_id, GoalStatus.PLANNING)
|
|
91
|
+
|
|
92
|
+
# 관련 메모리 수집
|
|
93
|
+
memories = self.memory.get_relevant(
|
|
94
|
+
goal["objective"],
|
|
95
|
+
project=cwd,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Planner 호출
|
|
99
|
+
dag, criteria = self.planner.create_plan(
|
|
100
|
+
objective=goal["objective"],
|
|
101
|
+
cwd=cwd,
|
|
102
|
+
memory_context=memories,
|
|
103
|
+
max_tasks=goal["max_tasks"],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# DAG를 Goal에 연결
|
|
107
|
+
goal = self.goal_engine.attach_dag(
|
|
108
|
+
goal_id,
|
|
109
|
+
dag=dag.to_dict(),
|
|
110
|
+
success_criteria=criteria,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
logger.info(
|
|
114
|
+
f"Plan created for {goal_id}: "
|
|
115
|
+
f"{len(dag.nodes)} tasks, {len(criteria)} criteria"
|
|
116
|
+
)
|
|
117
|
+
return goal
|
|
118
|
+
|
|
119
|
+
def execute(self, goal_id: str) -> dict:
|
|
120
|
+
"""계획된 DAG를 실행한다.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
실행 완료된 목표 dict
|
|
124
|
+
"""
|
|
125
|
+
goal = self.goal_engine.get_goal(goal_id)
|
|
126
|
+
if not goal or not goal["dag"]:
|
|
127
|
+
raise ValueError(f"Goal not found or no plan: {goal_id}")
|
|
128
|
+
|
|
129
|
+
cwd = goal["context"].get("cwd", ".")
|
|
130
|
+
self.goal_engine.update_status(goal_id, GoalStatus.RUNNING)
|
|
131
|
+
|
|
132
|
+
# Dispatcher 초기화
|
|
133
|
+
dispatcher = Dispatcher(
|
|
134
|
+
claude_bin=self.claude_bin,
|
|
135
|
+
logs_dir=str(self.base_dir / "logs" / "goals"),
|
|
136
|
+
prompts_dir=str(self.base_dir / "cognitive" / "prompts"),
|
|
137
|
+
max_concurrent=5,
|
|
138
|
+
on_task_complete=lambda tid, cost: self.goal_engine.update_task_status(
|
|
139
|
+
goal_id, tid, "completed", cost
|
|
140
|
+
),
|
|
141
|
+
on_task_fail=lambda tid, cost: self.goal_engine.update_task_status(
|
|
142
|
+
goal_id, tid, "failed", cost
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# DAG 실행
|
|
147
|
+
dag = TaskDAG.from_dict(goal["dag"])
|
|
148
|
+
completed_dag = dispatcher.run_dag(dag, cwd, goal_id)
|
|
149
|
+
|
|
150
|
+
# DAG 상태를 Goal에 반영
|
|
151
|
+
goal["dag"] = completed_dag.to_dict()
|
|
152
|
+
goal["updated_at"] = time.time()
|
|
153
|
+
self.goal_engine._save_goal(goal)
|
|
154
|
+
|
|
155
|
+
logger.info(f"Execution completed for {goal_id}")
|
|
156
|
+
return goal
|
|
157
|
+
|
|
158
|
+
def evaluate(self, goal_id: str) -> dict:
|
|
159
|
+
"""실행 결과를 평가하고 목표 달성 여부를 판단한다.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
{ "achieved": bool, "report": EvaluationReport, "goal": dict }
|
|
163
|
+
"""
|
|
164
|
+
goal = self.goal_engine.get_goal(goal_id)
|
|
165
|
+
if not goal:
|
|
166
|
+
raise ValueError(f"Goal not found: {goal_id}")
|
|
167
|
+
|
|
168
|
+
cwd = goal["context"].get("cwd", ".")
|
|
169
|
+
self.goal_engine.update_status(goal_id, GoalStatus.EVALUATING)
|
|
170
|
+
|
|
171
|
+
evaluator = Evaluator(self.claude_bin, cwd)
|
|
172
|
+
|
|
173
|
+
# 성공 기준 검증
|
|
174
|
+
eval_report = evaluator.evaluate_goal(
|
|
175
|
+
goal_id=goal_id,
|
|
176
|
+
success_criteria=goal.get("success_criteria", []),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# 태스크 완료 상태 검증
|
|
180
|
+
completion = self.goal_engine.evaluate_completion(goal_id)
|
|
181
|
+
|
|
182
|
+
result = {
|
|
183
|
+
"achieved": completion["achieved"] and eval_report.overall_pass,
|
|
184
|
+
"report": eval_report.to_dict(),
|
|
185
|
+
"completion": completion,
|
|
186
|
+
"goal": self.goal_engine.get_goal(goal_id),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# 학습
|
|
190
|
+
self.learning.record_outcome(
|
|
191
|
+
goal_id=goal_id,
|
|
192
|
+
objective=goal["objective"],
|
|
193
|
+
achieved=result["achieved"],
|
|
194
|
+
dag=goal["dag"],
|
|
195
|
+
cost_usd=goal["progress"]["cost_usd"],
|
|
196
|
+
eval_report=eval_report.to_dict(),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
def run(self, goal_id: str) -> dict:
|
|
202
|
+
"""인지 루프 전체를 실행한다: Plan → Execute → Evaluate.
|
|
203
|
+
|
|
204
|
+
Gate 모드에서는 각 단계 후 반환하여 사용자 승인을 기다린다.
|
|
205
|
+
Full Auto 모드에서는 끝까지 자율 실행한다.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
최종 평가 결과
|
|
209
|
+
"""
|
|
210
|
+
goal = self.goal_engine.get_goal(goal_id)
|
|
211
|
+
if not goal:
|
|
212
|
+
raise ValueError(f"Goal not found: {goal_id}")
|
|
213
|
+
|
|
214
|
+
mode = goal.get("mode", "gate")
|
|
215
|
+
|
|
216
|
+
# Phase 1: Plan
|
|
217
|
+
goal = self.plan(goal_id)
|
|
218
|
+
|
|
219
|
+
if mode == ExecutionMode.GATE.value:
|
|
220
|
+
self.goal_engine.update_status(goal_id, GoalStatus.GATE_WAITING)
|
|
221
|
+
return {
|
|
222
|
+
"phase": "plan_complete",
|
|
223
|
+
"message": "계획이 생성되었습니다. 승인 후 실행을 시작합니다.",
|
|
224
|
+
"goal": goal,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Phase 2: Execute
|
|
228
|
+
goal = self.execute(goal_id)
|
|
229
|
+
|
|
230
|
+
if mode == ExecutionMode.GATE.value:
|
|
231
|
+
self.goal_engine.update_status(goal_id, GoalStatus.GATE_WAITING)
|
|
232
|
+
return {
|
|
233
|
+
"phase": "execute_complete",
|
|
234
|
+
"message": "실행이 완료되었습니다. 평가를 시작하려면 승인하세요.",
|
|
235
|
+
"goal": goal,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
# Phase 3: Evaluate
|
|
239
|
+
result = self.evaluate(goal_id)
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
"phase": "done",
|
|
243
|
+
"message": "목표 달성" if result["achieved"] else "목표 미달성",
|
|
244
|
+
**result,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
def approve_gate(self, goal_id: str) -> dict:
|
|
248
|
+
"""Gate 모드에서 다음 단계를 승인한다."""
|
|
249
|
+
goal = self.goal_engine.get_goal(goal_id)
|
|
250
|
+
if not goal:
|
|
251
|
+
raise ValueError(f"Goal not found: {goal_id}")
|
|
252
|
+
|
|
253
|
+
if goal["status"] != GoalStatus.GATE_WAITING.value:
|
|
254
|
+
return {"error": "현재 승인 대기 상태가 아닙니다."}
|
|
255
|
+
|
|
256
|
+
# 현재 단계 판단
|
|
257
|
+
dag = goal.get("dag")
|
|
258
|
+
if dag is None:
|
|
259
|
+
# 계획이 아직 없음 → 오류
|
|
260
|
+
return {"error": "계획이 없습니다. plan을 먼저 실행하세요."}
|
|
261
|
+
|
|
262
|
+
tasks = dag.get("tasks", [])
|
|
263
|
+
any_running = any(t.get("status") == "running" for t in tasks)
|
|
264
|
+
all_done = all(t.get("status") in ("completed", "failed") for t in tasks)
|
|
265
|
+
|
|
266
|
+
if not any_running and not all_done:
|
|
267
|
+
# 계획 수립 완료 → 실행 단계로 진입
|
|
268
|
+
return self.execute(goal_id)
|
|
269
|
+
elif all_done:
|
|
270
|
+
# 실행 완료 → 평가 단계로 진입
|
|
271
|
+
return self.evaluate(goal_id)
|
|
272
|
+
else:
|
|
273
|
+
return {"error": "태스크가 아직 실행 중입니다."}
|
|
274
|
+
|
|
275
|
+
def get_status(self, goal_id: str) -> dict:
|
|
276
|
+
"""목표의 현재 상태를 반환한다."""
|
|
277
|
+
goal = self.goal_engine.get_goal(goal_id)
|
|
278
|
+
if not goal:
|
|
279
|
+
return {"error": "Goal not found"}
|
|
280
|
+
|
|
281
|
+
dag_info = None
|
|
282
|
+
if goal["dag"]:
|
|
283
|
+
dag = TaskDAG.from_dict(goal["dag"])
|
|
284
|
+
dag_info = {
|
|
285
|
+
"total": len(dag.nodes),
|
|
286
|
+
"completed": sum(1 for n in dag.nodes.values() if n.status == "completed"),
|
|
287
|
+
"running": sum(1 for n in dag.nodes.values() if n.status == "running"),
|
|
288
|
+
"failed": sum(1 for n in dag.nodes.values() if n.status == "failed"),
|
|
289
|
+
"pending": sum(1 for n in dag.nodes.values() if n.status == "pending"),
|
|
290
|
+
"mermaid": dag.to_mermaid(),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
"goal": {
|
|
295
|
+
"id": goal["id"],
|
|
296
|
+
"objective": goal["objective"],
|
|
297
|
+
"status": goal["status"],
|
|
298
|
+
"mode": goal["mode"],
|
|
299
|
+
"progress": goal["progress"],
|
|
300
|
+
"created_at": goal["created_at"],
|
|
301
|
+
},
|
|
302
|
+
"dag": dag_info,
|
|
303
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Planner — 목표를 실행 가능한 태스크 DAG로 변환
|
|
3
|
+
Claude를 사용하여 목표를 분석하고, 태스크를 분해하며, 의존성을 추론한다.
|
|
4
|
+
|
|
5
|
+
동작 방식:
|
|
6
|
+
1. Goal + 코드베이스 맥락 + Memory를 수집
|
|
7
|
+
2. Claude -p에게 구조화된 프롬프트로 DAG 생성 요청
|
|
8
|
+
3. 응답 JSON을 파싱하여 TaskDAG 객체 생성
|
|
9
|
+
4. DAG 유효성 검증 후 Goal에 연결
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from dag.graph import TaskDAG, TaskNode
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
PLANNER_SYSTEM_PROMPT = """당신은 소프트웨어 개발 프로젝트의 태스크 플래너입니다.
|
|
23
|
+
|
|
24
|
+
주어진 목표(Goal)를 분석하여 실행 가능한 태스크들로 분해하고,
|
|
25
|
+
각 태스크 간의 의존성을 DAG(방향성 비순환 그래프)로 구성합니다.
|
|
26
|
+
|
|
27
|
+
## 규칙
|
|
28
|
+
|
|
29
|
+
1. 각 태스크는 하나의 Claude headless 세션에서 완료 가능한 크기여야 한다
|
|
30
|
+
2. 태스크 간 의존성을 명확히 정의한다 (같은 파일 수정 시 직렬화)
|
|
31
|
+
3. 병렬 실행 가능한 태스크는 가능한 병렬로 구성한다
|
|
32
|
+
4. Worker 유형을 적절히 배정한다: analyst, coder, tester, reviewer, writer
|
|
33
|
+
5. 총 태스크 수는 {max_tasks}개 이하로 유지한다
|
|
34
|
+
6. 각 태스크의 프롬프트는 구체적이고 실행 가능해야 한다
|
|
35
|
+
|
|
36
|
+
## Worker 유형
|
|
37
|
+
|
|
38
|
+
- **analyst**: 코드 분석, 구조 파악, 영향 범위 조사 (읽기 전용)
|
|
39
|
+
- **coder**: 코드 작성/수정 (쓰기 작업)
|
|
40
|
+
- **tester**: 테스트 작성 및 실행
|
|
41
|
+
- **reviewer**: 코드 리뷰, 품질 검증
|
|
42
|
+
- **writer**: 문서 작성
|
|
43
|
+
|
|
44
|
+
## 출력 형식
|
|
45
|
+
|
|
46
|
+
반드시 아래 JSON 형식으로만 응답하세요:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{{
|
|
50
|
+
"success_criteria": ["기준1", "기준2"],
|
|
51
|
+
"tasks": [
|
|
52
|
+
{{
|
|
53
|
+
"id": "t1",
|
|
54
|
+
"name": "짧은 태스크 이름",
|
|
55
|
+
"worker_type": "analyst|coder|tester|reviewer|writer",
|
|
56
|
+
"prompt": "구체적인 실행 프롬프트",
|
|
57
|
+
"depends_on": []
|
|
58
|
+
}},
|
|
59
|
+
{{
|
|
60
|
+
"id": "t2",
|
|
61
|
+
"name": "다음 태스크",
|
|
62
|
+
"worker_type": "coder",
|
|
63
|
+
"prompt": "구체적인 프롬프트",
|
|
64
|
+
"depends_on": ["t1"]
|
|
65
|
+
}}
|
|
66
|
+
]
|
|
67
|
+
}}
|
|
68
|
+
```
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Planner:
|
|
73
|
+
"""목표를 태스크 DAG로 변환하는 계획 엔진."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, claude_bin: str, config: Optional[dict] = None):
|
|
76
|
+
self.claude_bin = claude_bin
|
|
77
|
+
self.config = config or {}
|
|
78
|
+
self.prompts_dir = Path(__file__).parent / "prompts"
|
|
79
|
+
|
|
80
|
+
def create_plan(
|
|
81
|
+
self,
|
|
82
|
+
objective: str,
|
|
83
|
+
cwd: str,
|
|
84
|
+
memory_context: list[dict] = None,
|
|
85
|
+
max_tasks: int = 20,
|
|
86
|
+
) -> tuple[TaskDAG, list[str]]:
|
|
87
|
+
"""목표로부터 태스크 DAG를 생성한다.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
objective: 자연어 목표
|
|
91
|
+
cwd: 작업 디렉토리
|
|
92
|
+
memory_context: 관련 메모리 목록
|
|
93
|
+
max_tasks: 최대 태스크 수
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
(TaskDAG, success_criteria)
|
|
97
|
+
"""
|
|
98
|
+
prompt = self._build_prompt(objective, cwd, memory_context, max_tasks)
|
|
99
|
+
response = self._call_claude(prompt, cwd)
|
|
100
|
+
dag, criteria = self._parse_response(response)
|
|
101
|
+
return dag, criteria
|
|
102
|
+
|
|
103
|
+
def _build_prompt(
|
|
104
|
+
self,
|
|
105
|
+
objective: str,
|
|
106
|
+
cwd: str,
|
|
107
|
+
memory_context: Optional[list[dict]],
|
|
108
|
+
max_tasks: int,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Planner용 프롬프트를 조립한다."""
|
|
111
|
+
parts = [
|
|
112
|
+
f"# 목표\n{objective}\n",
|
|
113
|
+
f"# 작업 디렉토리\n{cwd}\n",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
if memory_context:
|
|
117
|
+
parts.append("# 관련 지식 (Memory)\n")
|
|
118
|
+
for mem in memory_context[:5]:
|
|
119
|
+
parts.append(
|
|
120
|
+
f"- [{mem['type']}] {mem['title']}: {mem['content'][:200]}\n"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
parts.append(
|
|
124
|
+
f"\n위 목표를 달성하기 위한 태스크 DAG를 생성하세요. "
|
|
125
|
+
f"최대 {max_tasks}개 태스크로 제한합니다."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return "\n".join(parts)
|
|
129
|
+
|
|
130
|
+
def _call_claude(self, prompt: str, cwd: str) -> str:
|
|
131
|
+
"""Claude headless를 호출하여 계획을 생성한다."""
|
|
132
|
+
system_prompt = PLANNER_SYSTEM_PROMPT.format(
|
|
133
|
+
max_tasks=self.config.get("max_tasks", 20)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
cmd = [
|
|
137
|
+
self.claude_bin,
|
|
138
|
+
"-p", prompt,
|
|
139
|
+
"--output-format", "json",
|
|
140
|
+
"--allowedTools", "Read,Glob,Grep,Bash",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
if system_prompt:
|
|
144
|
+
cmd.extend(["--append-system-prompt", system_prompt])
|
|
145
|
+
|
|
146
|
+
env = os.environ.copy()
|
|
147
|
+
result = subprocess.run(
|
|
148
|
+
cmd,
|
|
149
|
+
cwd=cwd,
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
env=env,
|
|
153
|
+
timeout=300,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if result.returncode != 0:
|
|
157
|
+
raise RuntimeError(f"Planner claude call failed: {result.stderr[:500]}")
|
|
158
|
+
|
|
159
|
+
return result.stdout
|
|
160
|
+
|
|
161
|
+
def _parse_response(self, response: str) -> tuple[TaskDAG, list[str]]:
|
|
162
|
+
"""Claude 응답에서 DAG를 파싱한다."""
|
|
163
|
+
# JSON 블록 추출 (```json ... ``` 또는 순수 JSON)
|
|
164
|
+
text = response.strip()
|
|
165
|
+
|
|
166
|
+
# output-format json일 경우 최상위 JSON 파싱
|
|
167
|
+
try:
|
|
168
|
+
outer = json.loads(text)
|
|
169
|
+
if "result" in outer:
|
|
170
|
+
text = outer["result"]
|
|
171
|
+
elif "content" in outer:
|
|
172
|
+
# content 배열에서 텍스트 추출
|
|
173
|
+
for block in outer.get("content", []):
|
|
174
|
+
if block.get("type") == "text":
|
|
175
|
+
text = block["text"]
|
|
176
|
+
break
|
|
177
|
+
except (json.JSONDecodeError, TypeError):
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
# ```json``` 블록 추출
|
|
181
|
+
if "```json" in text:
|
|
182
|
+
start = text.index("```json") + 7
|
|
183
|
+
end = text.index("```", start)
|
|
184
|
+
text = text[start:end].strip()
|
|
185
|
+
elif "```" in text:
|
|
186
|
+
start = text.index("```") + 3
|
|
187
|
+
end = text.index("```", start)
|
|
188
|
+
text = text[start:end].strip()
|
|
189
|
+
|
|
190
|
+
data = json.loads(text)
|
|
191
|
+
criteria = data.get("success_criteria", [])
|
|
192
|
+
|
|
193
|
+
dag = TaskDAG()
|
|
194
|
+
for td in data.get("tasks", []):
|
|
195
|
+
dag.add_task(TaskNode(
|
|
196
|
+
task_id=td["id"],
|
|
197
|
+
name=td["name"],
|
|
198
|
+
worker_type=td["worker_type"],
|
|
199
|
+
prompt=td["prompt"],
|
|
200
|
+
depends_on=td.get("depends_on", []),
|
|
201
|
+
))
|
|
202
|
+
|
|
203
|
+
valid, msg = dag.validate()
|
|
204
|
+
if not valid:
|
|
205
|
+
raise ValueError(f"Invalid DAG: {msg}")
|
|
206
|
+
|
|
207
|
+
return dag, criteria
|