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.
Files changed (71) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +1189 -0
  4. package/bin/native-app.py +6 -3
  5. package/bin/watchdog.sh +357 -0
  6. package/cognitive/__init__.py +14 -0
  7. package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
  9. package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
  10. package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
  11. package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
  12. package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
  13. package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
  14. package/cognitive/dispatcher.py +192 -0
  15. package/cognitive/evaluator.py +289 -0
  16. package/cognitive/goal_engine.py +232 -0
  17. package/cognitive/learning.py +189 -0
  18. package/cognitive/orchestrator.py +303 -0
  19. package/cognitive/planner.py +207 -0
  20. package/cognitive/prompts/analyst.md +31 -0
  21. package/cognitive/prompts/coder.md +22 -0
  22. package/cognitive/prompts/reviewer.md +33 -0
  23. package/cognitive/prompts/tester.md +21 -0
  24. package/cognitive/prompts/writer.md +25 -0
  25. package/config.sh +6 -1
  26. package/dag/__init__.py +5 -0
  27. package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/dag/__pycache__/graph.cpython-314.pyc +0 -0
  29. package/dag/graph.py +222 -0
  30. package/lib/jobs.sh +12 -1
  31. package/package.json +11 -5
  32. package/postinstall.sh +1 -1
  33. package/service/controller.sh +43 -11
  34. package/web/audit.py +122 -0
  35. package/web/checkpoint.py +80 -0
  36. package/web/config.py +2 -5
  37. package/web/handler.py +634 -473
  38. package/web/handler_fs.py +153 -0
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +372 -0
  41. package/web/handler_memory.py +203 -0
  42. package/web/handler_sessions.py +132 -0
  43. package/web/jobs.py +585 -13
  44. package/web/personas.py +419 -0
  45. package/web/pipeline.py +981 -0
  46. package/web/presets.py +506 -0
  47. package/web/projects.py +246 -0
  48. package/web/static/api.js +141 -0
  49. package/web/static/app.js +25 -1937
  50. package/web/static/attachments.js +144 -0
  51. package/web/static/base.css +497 -0
  52. package/web/static/context.js +204 -0
  53. package/web/static/dirs.js +246 -0
  54. package/web/static/form.css +763 -0
  55. package/web/static/goals.css +363 -0
  56. package/web/static/goals.js +300 -0
  57. package/web/static/i18n.js +625 -0
  58. package/web/static/index.html +215 -13
  59. package/web/static/{styles.css → jobs.css} +746 -1141
  60. package/web/static/jobs.js +1270 -0
  61. package/web/static/memoryview.js +117 -0
  62. package/web/static/personas.js +228 -0
  63. package/web/static/pipeline.css +338 -0
  64. package/web/static/pipelines.js +487 -0
  65. package/web/static/presets.js +244 -0
  66. package/web/static/send.js +135 -0
  67. package/web/static/settings-style.css +291 -0
  68. package/web/static/settings.js +81 -0
  69. package/web/static/stream.js +534 -0
  70. package/web/static/utils.js +131 -0
  71. 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)