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,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File System & Config HTTP 핸들러 Mixin
|
|
3
|
+
|
|
4
|
+
포함 엔드포인트:
|
|
5
|
+
- GET/POST /api/config
|
|
6
|
+
- GET/POST /api/recent-dirs
|
|
7
|
+
- GET /api/dirs
|
|
8
|
+
- POST /api/mkdir
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from config import DATA_DIR, SETTINGS_FILE, RECENT_DIRS_FILE
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FsHandlerMixin:
|
|
18
|
+
|
|
19
|
+
def _handle_get_config(self):
|
|
20
|
+
defaults = {
|
|
21
|
+
"skip_permissions": False,
|
|
22
|
+
"allowed_tools": "Bash,Read,Write,Edit,Glob,Grep,Agent,NotebookEdit,WebFetch,WebSearch",
|
|
23
|
+
"model": "",
|
|
24
|
+
"max_jobs": 10,
|
|
25
|
+
"append_system_prompt": "",
|
|
26
|
+
"target_repo": "",
|
|
27
|
+
"base_branch": "main",
|
|
28
|
+
"checkpoint_interval": 5,
|
|
29
|
+
"locale": "ko",
|
|
30
|
+
}
|
|
31
|
+
try:
|
|
32
|
+
if SETTINGS_FILE.exists():
|
|
33
|
+
saved = json.loads(SETTINGS_FILE.read_text("utf-8"))
|
|
34
|
+
defaults.update(saved)
|
|
35
|
+
except (json.JSONDecodeError, OSError):
|
|
36
|
+
pass
|
|
37
|
+
self._json_response(defaults)
|
|
38
|
+
|
|
39
|
+
def _handle_save_config(self):
|
|
40
|
+
body = self._read_body()
|
|
41
|
+
if not body or not isinstance(body, dict):
|
|
42
|
+
return self._error_response("설정 데이터가 필요합니다", code="MISSING_FIELD")
|
|
43
|
+
|
|
44
|
+
current = {}
|
|
45
|
+
try:
|
|
46
|
+
if SETTINGS_FILE.exists():
|
|
47
|
+
current = json.loads(SETTINGS_FILE.read_text("utf-8"))
|
|
48
|
+
except (json.JSONDecodeError, OSError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
allowed_keys = {
|
|
52
|
+
"skip_permissions", "allowed_tools", "model", "max_jobs",
|
|
53
|
+
"append_system_prompt", "target_repo", "base_branch",
|
|
54
|
+
"checkpoint_interval", "locale",
|
|
55
|
+
"webhook_url", "webhook_secret", "webhook_events",
|
|
56
|
+
}
|
|
57
|
+
for k, v in body.items():
|
|
58
|
+
if k in allowed_keys:
|
|
59
|
+
current[k] = v
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
SETTINGS_FILE.write_text(
|
|
64
|
+
json.dumps(current, ensure_ascii=False, indent=2), "utf-8"
|
|
65
|
+
)
|
|
66
|
+
self._json_response({"ok": True, "config": current})
|
|
67
|
+
except OSError as e:
|
|
68
|
+
self._error_response(f"설정 저장 실패: {e}", 500, code="CONFIG_SAVE_FAILED")
|
|
69
|
+
|
|
70
|
+
def _handle_get_recent_dirs(self):
|
|
71
|
+
try:
|
|
72
|
+
if RECENT_DIRS_FILE.exists():
|
|
73
|
+
data = json.loads(RECENT_DIRS_FILE.read_text("utf-8"))
|
|
74
|
+
else:
|
|
75
|
+
data = []
|
|
76
|
+
self._json_response(data)
|
|
77
|
+
except (json.JSONDecodeError, OSError):
|
|
78
|
+
self._json_response([])
|
|
79
|
+
|
|
80
|
+
def _handle_save_recent_dirs(self):
|
|
81
|
+
body = self._read_body()
|
|
82
|
+
dirs = body.get("dirs")
|
|
83
|
+
if not isinstance(dirs, list):
|
|
84
|
+
return self._error_response("dirs 배열이 필요합니다", code="MISSING_FIELD")
|
|
85
|
+
dirs = [d for d in dirs if isinstance(d, str)][:8]
|
|
86
|
+
try:
|
|
87
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
RECENT_DIRS_FILE.write_text(json.dumps(dirs, ensure_ascii=False), "utf-8")
|
|
89
|
+
self._json_response({"ok": True})
|
|
90
|
+
except OSError as e:
|
|
91
|
+
self._error_response(f"저장 실패: {e}", 500, code="SAVE_FAILED")
|
|
92
|
+
|
|
93
|
+
def _handle_dirs(self, dir_path):
|
|
94
|
+
try:
|
|
95
|
+
dir_path = os.path.abspath(os.path.expanduser(dir_path))
|
|
96
|
+
if not os.path.isdir(dir_path):
|
|
97
|
+
return self._error_response("디렉토리가 아닙니다", 400, code="NOT_A_DIRECTORY")
|
|
98
|
+
|
|
99
|
+
entries = []
|
|
100
|
+
try:
|
|
101
|
+
items = sorted(os.listdir(dir_path))
|
|
102
|
+
except PermissionError:
|
|
103
|
+
return self._error_response("접근 권한 없음", 403, code="PERMISSION_DENIED")
|
|
104
|
+
|
|
105
|
+
parent = os.path.dirname(dir_path)
|
|
106
|
+
if parent != dir_path:
|
|
107
|
+
entries.append({"name": "..", "path": parent, "type": "dir"})
|
|
108
|
+
|
|
109
|
+
for item in items:
|
|
110
|
+
if item.startswith("."):
|
|
111
|
+
continue
|
|
112
|
+
full = os.path.join(dir_path, item)
|
|
113
|
+
entry = {"name": item, "path": full}
|
|
114
|
+
if os.path.isdir(full):
|
|
115
|
+
entry["type"] = "dir"
|
|
116
|
+
else:
|
|
117
|
+
entry["type"] = "file"
|
|
118
|
+
try:
|
|
119
|
+
entry["size"] = os.path.getsize(full)
|
|
120
|
+
except OSError:
|
|
121
|
+
entry["size"] = 0
|
|
122
|
+
entries.append(entry)
|
|
123
|
+
|
|
124
|
+
self._json_response({"current": dir_path, "entries": entries})
|
|
125
|
+
except Exception as e:
|
|
126
|
+
self._error_response(f"디렉토리 읽기 실패: {e}", 500, code="DIR_READ_ERROR")
|
|
127
|
+
|
|
128
|
+
def _handle_mkdir(self):
|
|
129
|
+
body = self._read_body()
|
|
130
|
+
parent = body.get("parent", "").strip()
|
|
131
|
+
name = body.get("name", "").strip()
|
|
132
|
+
|
|
133
|
+
if not parent or not name:
|
|
134
|
+
return self._error_response("parent와 name 필드가 필요합니다", code="MISSING_FIELD")
|
|
135
|
+
|
|
136
|
+
if "/" in name or "\\" in name or name in (".", ".."):
|
|
137
|
+
return self._error_response("잘못된 디렉토리 이름입니다", code="INVALID_NAME")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
parent = os.path.abspath(os.path.expanduser(parent))
|
|
141
|
+
if not os.path.isdir(parent):
|
|
142
|
+
return self._error_response("상위 디렉토리가 존재하지 않습니다", 400, code="DIR_NOT_FOUND")
|
|
143
|
+
|
|
144
|
+
new_dir = os.path.join(parent, name)
|
|
145
|
+
if os.path.exists(new_dir):
|
|
146
|
+
return self._error_response("이미 존재하는 이름입니다", 409, code="ALREADY_EXISTS")
|
|
147
|
+
|
|
148
|
+
os.makedirs(new_dir)
|
|
149
|
+
self._json_response({"ok": True, "path": new_dir}, 201)
|
|
150
|
+
except PermissionError:
|
|
151
|
+
self._error_response("접근 권한 없음", 403, code="PERMISSION_DENIED")
|
|
152
|
+
except OSError as e:
|
|
153
|
+
self._error_response(f"디렉토리 생성 실패: {e}", 500, code="DIR_CREATE_ERROR")
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Goal 관련 HTTP 핸들러 Mixin
|
|
3
|
+
|
|
4
|
+
포함 엔드포인트:
|
|
5
|
+
- GET /api/goals # 목표 목록 (status 필터 가능)
|
|
6
|
+
- GET /api/goals/:id # 목표 상세 (DAG 포함)
|
|
7
|
+
- POST /api/goals # 목표 생성
|
|
8
|
+
- POST /api/goals/:id/update # 목표 수정 (mode, budget 변경)
|
|
9
|
+
- POST /api/goals/:id/approve # Gate 모드: 다음 단계 승인
|
|
10
|
+
- DELETE /api/goals/:id # 목표 취소
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from urllib.parse import parse_qs
|
|
16
|
+
|
|
17
|
+
from config import CONTROLLER_DIR, DATA_DIR
|
|
18
|
+
|
|
19
|
+
# cognitive 패키지를 import 경로에 추가
|
|
20
|
+
if str(CONTROLLER_DIR) not in sys.path:
|
|
21
|
+
sys.path.insert(0, str(CONTROLLER_DIR))
|
|
22
|
+
|
|
23
|
+
from cognitive.goal_engine import GoalEngine, GoalStatus, ExecutionMode
|
|
24
|
+
|
|
25
|
+
# 모듈 수준 싱글턴
|
|
26
|
+
_goal_engine = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_engine():
|
|
30
|
+
global _goal_engine
|
|
31
|
+
if _goal_engine is None:
|
|
32
|
+
_goal_engine = GoalEngine(str(DATA_DIR))
|
|
33
|
+
return _goal_engine
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GoalHandlerMixin:
|
|
37
|
+
|
|
38
|
+
def _handle_list_goals(self, parsed):
|
|
39
|
+
"""GET /api/goals — 목표 목록"""
|
|
40
|
+
qs = parse_qs(parsed.query)
|
|
41
|
+
status = qs.get("status", [None])[0]
|
|
42
|
+
goals = _get_engine().list_goals(status=status)
|
|
43
|
+
self._json_response(goals)
|
|
44
|
+
|
|
45
|
+
def _handle_get_goal(self, goal_id):
|
|
46
|
+
"""GET /api/goals/:id — 목표 상세 (DAG, 진행률 포함)"""
|
|
47
|
+
goal = _get_engine().get_goal(goal_id)
|
|
48
|
+
if goal is None:
|
|
49
|
+
return self._error_response(
|
|
50
|
+
"목표를 찾을 수 없습니다", 404, code="GOAL_NOT_FOUND")
|
|
51
|
+
# 실행 가능한 다음 태스크 정보도 포함
|
|
52
|
+
next_tasks = _get_engine().get_next_tasks(goal_id)
|
|
53
|
+
goal["next_tasks"] = next_tasks
|
|
54
|
+
self._json_response(goal)
|
|
55
|
+
|
|
56
|
+
def _handle_create_goal(self):
|
|
57
|
+
"""POST /api/goals — 목표 생성"""
|
|
58
|
+
body = self._read_body()
|
|
59
|
+
objective = body.get("objective", "").strip()
|
|
60
|
+
if not objective:
|
|
61
|
+
return self._error_response(
|
|
62
|
+
"objective 필드가 필요합니다", 400, code="MISSING_FIELD")
|
|
63
|
+
|
|
64
|
+
mode_str = body.get("mode", "gate")
|
|
65
|
+
try:
|
|
66
|
+
mode = ExecutionMode(mode_str)
|
|
67
|
+
except ValueError:
|
|
68
|
+
valid = [m.value for m in ExecutionMode]
|
|
69
|
+
return self._error_response(
|
|
70
|
+
f"유효하지 않은 mode: {mode_str}. 가능한 값: {valid}",
|
|
71
|
+
400, code="INVALID_MODE")
|
|
72
|
+
|
|
73
|
+
context = body.get("context", {})
|
|
74
|
+
if not isinstance(context, dict):
|
|
75
|
+
return self._error_response(
|
|
76
|
+
"context는 JSON 객체여야 합니다", 400, code="INVALID_PARAM")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
budget_usd = float(body.get("budget_usd", 5.0))
|
|
80
|
+
max_tasks = int(body.get("max_tasks", 20))
|
|
81
|
+
except (ValueError, TypeError):
|
|
82
|
+
return self._error_response(
|
|
83
|
+
"budget_usd 또는 max_tasks 값이 유효하지 않습니다",
|
|
84
|
+
400, code="INVALID_PARAM")
|
|
85
|
+
|
|
86
|
+
if budget_usd <= 0:
|
|
87
|
+
return self._error_response(
|
|
88
|
+
"budget_usd는 0보다 커야 합니다", 400, code="INVALID_PARAM")
|
|
89
|
+
if max_tasks < 1:
|
|
90
|
+
return self._error_response(
|
|
91
|
+
"max_tasks는 1 이상이어야 합니다", 400, code="INVALID_PARAM")
|
|
92
|
+
|
|
93
|
+
goal = _get_engine().create_goal(
|
|
94
|
+
objective=objective,
|
|
95
|
+
mode=mode,
|
|
96
|
+
context=context,
|
|
97
|
+
budget_usd=budget_usd,
|
|
98
|
+
max_tasks=max_tasks,
|
|
99
|
+
)
|
|
100
|
+
self._json_response(goal, 201)
|
|
101
|
+
|
|
102
|
+
def _handle_update_goal(self, goal_id):
|
|
103
|
+
"""POST /api/goals/:id/update — 목표 수정 (mode, budget 변경)"""
|
|
104
|
+
engine = _get_engine()
|
|
105
|
+
goal = engine.get_goal(goal_id)
|
|
106
|
+
if goal is None:
|
|
107
|
+
return self._error_response(
|
|
108
|
+
"목표를 찾을 수 없습니다", 404, code="GOAL_NOT_FOUND")
|
|
109
|
+
|
|
110
|
+
if goal["status"] in (GoalStatus.COMPLETED.value,
|
|
111
|
+
GoalStatus.FAILED.value,
|
|
112
|
+
GoalStatus.CANCELLED.value):
|
|
113
|
+
return self._error_response(
|
|
114
|
+
f"종료된 목표({goal['status']})는 수정할 수 없습니다",
|
|
115
|
+
409, code="GOAL_ALREADY_FINISHED")
|
|
116
|
+
|
|
117
|
+
body = self._read_body()
|
|
118
|
+
changed = False
|
|
119
|
+
|
|
120
|
+
if "mode" in body:
|
|
121
|
+
try:
|
|
122
|
+
mode = ExecutionMode(body["mode"])
|
|
123
|
+
goal["mode"] = mode.value
|
|
124
|
+
changed = True
|
|
125
|
+
except ValueError:
|
|
126
|
+
valid = [m.value for m in ExecutionMode]
|
|
127
|
+
return self._error_response(
|
|
128
|
+
f"유효하지 않은 mode: {body['mode']}. 가능한 값: {valid}",
|
|
129
|
+
400, code="INVALID_MODE")
|
|
130
|
+
|
|
131
|
+
if "budget_usd" in body:
|
|
132
|
+
try:
|
|
133
|
+
budget = float(body["budget_usd"])
|
|
134
|
+
if budget <= 0:
|
|
135
|
+
raise ValueError
|
|
136
|
+
goal["budget_usd"] = budget
|
|
137
|
+
changed = True
|
|
138
|
+
except (ValueError, TypeError):
|
|
139
|
+
return self._error_response(
|
|
140
|
+
"budget_usd는 0보다 큰 숫자여야 합니다",
|
|
141
|
+
400, code="INVALID_PARAM")
|
|
142
|
+
|
|
143
|
+
if "max_tasks" in body:
|
|
144
|
+
try:
|
|
145
|
+
mt = int(body["max_tasks"])
|
|
146
|
+
if mt < 1:
|
|
147
|
+
raise ValueError
|
|
148
|
+
goal["max_tasks"] = mt
|
|
149
|
+
changed = True
|
|
150
|
+
except (ValueError, TypeError):
|
|
151
|
+
return self._error_response(
|
|
152
|
+
"max_tasks는 1 이상의 정수여야 합니다",
|
|
153
|
+
400, code="INVALID_PARAM")
|
|
154
|
+
|
|
155
|
+
if not changed:
|
|
156
|
+
return self._error_response(
|
|
157
|
+
"변경할 필드가 없습니다. mode, budget_usd, max_tasks 중 하나를 지정하세요.",
|
|
158
|
+
400, code="NO_CHANGES")
|
|
159
|
+
|
|
160
|
+
goal["updated_at"] = time.time()
|
|
161
|
+
engine._save_goal(goal)
|
|
162
|
+
self._json_response(goal)
|
|
163
|
+
|
|
164
|
+
def _handle_approve_goal(self, goal_id):
|
|
165
|
+
"""POST /api/goals/:id/approve — Gate 모드: 다음 단계 승인"""
|
|
166
|
+
engine = _get_engine()
|
|
167
|
+
goal = engine.get_goal(goal_id)
|
|
168
|
+
if goal is None:
|
|
169
|
+
return self._error_response(
|
|
170
|
+
"목표를 찾을 수 없습니다", 404, code="GOAL_NOT_FOUND")
|
|
171
|
+
|
|
172
|
+
if goal["status"] != GoalStatus.GATE_WAITING.value:
|
|
173
|
+
return self._error_response(
|
|
174
|
+
f"현재 상태({goal['status']})에서는 승인할 수 없습니다. "
|
|
175
|
+
"gate_waiting 상태일 때만 가능합니다.",
|
|
176
|
+
409, code="INVALID_STATE_TRANSITION")
|
|
177
|
+
|
|
178
|
+
engine.update_status(goal_id, GoalStatus.RUNNING)
|
|
179
|
+
next_tasks = engine.get_next_tasks(goal_id)
|
|
180
|
+
goal = engine.get_goal(goal_id)
|
|
181
|
+
|
|
182
|
+
self._json_response({
|
|
183
|
+
"goal": goal,
|
|
184
|
+
"next_tasks": next_tasks,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
def _handle_cancel_goal(self, goal_id):
|
|
188
|
+
"""DELETE /api/goals/:id — 목표 취소"""
|
|
189
|
+
engine = _get_engine()
|
|
190
|
+
goal = engine.get_goal(goal_id)
|
|
191
|
+
if goal is None:
|
|
192
|
+
return self._error_response(
|
|
193
|
+
"목표를 찾을 수 없습니다", 404, code="GOAL_NOT_FOUND")
|
|
194
|
+
|
|
195
|
+
if goal["status"] in (GoalStatus.COMPLETED.value,
|
|
196
|
+
GoalStatus.FAILED.value,
|
|
197
|
+
GoalStatus.CANCELLED.value):
|
|
198
|
+
return self._error_response(
|
|
199
|
+
f"이미 종료된 목표({goal['status']})는 취소할 수 없습니다",
|
|
200
|
+
409, code="GOAL_ALREADY_FINISHED")
|
|
201
|
+
|
|
202
|
+
goal = engine.cancel_goal(goal_id)
|
|
203
|
+
self._json_response(goal)
|