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,246 @@
1
+ """
2
+ Controller Service — 프로젝트 관리
3
+
4
+ 프로젝트는 자동화 대상 저장소/디렉토리를 등록·관리하는 단위이다.
5
+ 기존 프로젝트를 등록하거나, 신규 프로젝트를 생성(디렉토리 + git init)할 수 있다.
6
+ 저장: data/projects.json
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from config import DATA_DIR, LOGS_DIR
16
+ from utils import parse_meta_file
17
+
18
+ PROJECTS_FILE = DATA_DIR / "projects.json"
19
+
20
+
21
+ def _load_projects() -> list[dict]:
22
+ """프로젝트 목록을 파일에서 읽는다."""
23
+ try:
24
+ if PROJECTS_FILE.exists():
25
+ return json.loads(PROJECTS_FILE.read_text("utf-8"))
26
+ except (json.JSONDecodeError, OSError):
27
+ pass
28
+ return []
29
+
30
+
31
+ def _save_projects(projects: list[dict]):
32
+ """프로젝트 목록을 파일에 저장한다."""
33
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
34
+ PROJECTS_FILE.write_text(
35
+ json.dumps(projects, ensure_ascii=False, indent=2), "utf-8"
36
+ )
37
+
38
+
39
+ def _generate_id() -> str:
40
+ return f"{int(time.time())}-{os.getpid()}-{id(time) % 10000}"
41
+
42
+
43
+ def _collect_job_stats_by_cwd() -> dict:
44
+ """logs/ 디렉토리의 .meta 파일을 순회하여 cwd별 job 통계를 집계한다."""
45
+ stats = {}
46
+ if not LOGS_DIR.exists():
47
+ return stats
48
+ for mf in LOGS_DIR.glob("job_*.meta"):
49
+ meta = parse_meta_file(mf)
50
+ if not meta:
51
+ continue
52
+ cwd = meta.get("CWD", "")
53
+ if not cwd:
54
+ continue
55
+ norm = os.path.normpath(cwd)
56
+ if norm not in stats:
57
+ stats[norm] = {"total": 0, "running": 0, "done": 0, "failed": 0, "cost": 0.0}
58
+ s = stats[norm]
59
+ s["total"] += 1
60
+ status = meta.get("STATUS", "unknown")
61
+ if status == "running":
62
+ pid = meta.get("PID")
63
+ if pid:
64
+ try:
65
+ os.kill(int(pid), 0)
66
+ except (ProcessLookupError, ValueError, OSError):
67
+ status = "done"
68
+ if status == "running":
69
+ s["running"] += 1
70
+ elif status == "done":
71
+ s["done"] += 1
72
+ elif status == "failed":
73
+ s["failed"] += 1
74
+ return stats
75
+
76
+
77
+ def _detect_git_info(path: str) -> dict:
78
+ """디렉토리의 git 정보를 감지한다."""
79
+ info = {"is_git": False, "branch": "", "remote": ""}
80
+ try:
81
+ result = subprocess.run(
82
+ ["git", "rev-parse", "--is-inside-work-tree"],
83
+ cwd=path, capture_output=True, text=True, timeout=5,
84
+ )
85
+ if result.returncode != 0:
86
+ return info
87
+ info["is_git"] = True
88
+
89
+ result = subprocess.run(
90
+ ["git", "branch", "--show-current"],
91
+ cwd=path, capture_output=True, text=True, timeout=5,
92
+ )
93
+ info["branch"] = result.stdout.strip()
94
+
95
+ result = subprocess.run(
96
+ ["git", "remote", "get-url", "origin"],
97
+ cwd=path, capture_output=True, text=True, timeout=5,
98
+ )
99
+ info["remote"] = result.stdout.strip()
100
+ except (subprocess.TimeoutExpired, OSError):
101
+ pass
102
+ return info
103
+
104
+
105
+ # ══════════════════════════════════════════════════════════════
106
+ # CRUD
107
+ # ══════════════════════════════════════════════════════════════
108
+
109
+ def list_projects(include_job_stats=True) -> list[dict]:
110
+ """등록된 프로젝트 목록을 반환한다.
111
+
112
+ Args:
113
+ include_job_stats: True이면 각 프로젝트의 job 통계를 포함한다.
114
+ """
115
+ projects = _load_projects()
116
+
117
+ # job 통계를 위한 cwd→count 맵 (한 번만 순회)
118
+ job_stats = {}
119
+ if include_job_stats:
120
+ job_stats = _collect_job_stats_by_cwd()
121
+
122
+ for p in projects:
123
+ p["exists"] = os.path.isdir(p.get("path", ""))
124
+ if include_job_stats:
125
+ norm_path = os.path.normpath(p.get("path", ""))
126
+ stats = job_stats.get(norm_path, {})
127
+ p["job_stats"] = {
128
+ "total": stats.get("total", 0),
129
+ "running": stats.get("running", 0),
130
+ "done": stats.get("done", 0),
131
+ "failed": stats.get("failed", 0),
132
+ "total_cost": round(stats.get("cost", 0), 4),
133
+ }
134
+ return projects
135
+
136
+
137
+ def get_project(project_id: str) -> tuple[dict | None, str | None]:
138
+ """ID로 프로젝트를 조회한다."""
139
+ projects = _load_projects()
140
+ for p in projects:
141
+ if p["id"] == project_id:
142
+ p["exists"] = os.path.isdir(p.get("path", ""))
143
+ git_info = _detect_git_info(p["path"]) if p["exists"] else {}
144
+ p.update(git_info)
145
+ return p, None
146
+ return None, "프로젝트를 찾을 수 없습니다"
147
+
148
+
149
+ def add_project(path: str, name: str = "", description: str = "") -> tuple[dict | None, str | None]:
150
+ """기존 디렉토리를 프로젝트로 등록한다."""
151
+ path = os.path.abspath(os.path.expanduser(path))
152
+ if not os.path.isdir(path):
153
+ return None, f"디렉토리가 존재하지 않습니다: {path}"
154
+
155
+ projects = _load_projects()
156
+
157
+ # 중복 체크
158
+ for p in projects:
159
+ if os.path.normpath(p["path"]) == os.path.normpath(path):
160
+ return None, f"이미 등록된 프로젝트입니다: {p['name']} ({p['id']})"
161
+
162
+ if not name:
163
+ name = os.path.basename(path)
164
+
165
+ git_info = _detect_git_info(path)
166
+ now = time.strftime("%Y-%m-%dT%H:%M:%S")
167
+
168
+ project = {
169
+ "id": _generate_id(),
170
+ "name": name,
171
+ "path": path,
172
+ "description": description,
173
+ "is_git": git_info["is_git"],
174
+ "branch": git_info["branch"],
175
+ "remote": git_info["remote"],
176
+ "created_at": now,
177
+ "last_used_at": now,
178
+ }
179
+
180
+ projects.append(project)
181
+ _save_projects(projects)
182
+ return project, None
183
+
184
+
185
+ def create_project(path: str, name: str = "", description: str = "",
186
+ init_git: bool = True) -> tuple[dict | None, str | None]:
187
+ """신규 프로젝트를 생성한다 (디렉토리 생성 + git init + 등록)."""
188
+ path = os.path.abspath(os.path.expanduser(path))
189
+
190
+ if os.path.exists(path):
191
+ return None, f"이미 존재하는 경로입니다: {path}"
192
+
193
+ try:
194
+ os.makedirs(path)
195
+ except OSError as e:
196
+ return None, f"디렉토리 생성 실패: {e}"
197
+
198
+ if init_git:
199
+ try:
200
+ subprocess.run(
201
+ ["git", "init"],
202
+ cwd=path, capture_output=True, text=True, timeout=10, check=True,
203
+ )
204
+ except (subprocess.CalledProcessError, OSError) as e:
205
+ return None, f"git init 실패: {e}"
206
+
207
+ if not name:
208
+ name = os.path.basename(path)
209
+
210
+ return add_project(path, name=name, description=description)
211
+
212
+
213
+ def remove_project(project_id: str) -> tuple[dict | None, str | None]:
214
+ """프로젝트 등록을 해제한다 (디렉토리는 삭제하지 않음)."""
215
+ projects = _load_projects()
216
+ for i, p in enumerate(projects):
217
+ if p["id"] == project_id:
218
+ removed = projects.pop(i)
219
+ _save_projects(projects)
220
+ return removed, None
221
+ return None, "프로젝트를 찾을 수 없습니다"
222
+
223
+
224
+ def update_project(project_id: str, **kwargs) -> tuple[dict | None, str | None]:
225
+ """프로젝트 정보를 업데이트한다."""
226
+ projects = _load_projects()
227
+ allowed = {"name", "description"}
228
+ for p in projects:
229
+ if p["id"] == project_id:
230
+ for k, v in kwargs.items():
231
+ if k in allowed and v is not None:
232
+ p[k] = v
233
+ p["last_used_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
234
+ _save_projects(projects)
235
+ return p, None
236
+ return None, "프로젝트를 찾을 수 없습니다"
237
+
238
+
239
+ def touch_project(project_id: str):
240
+ """last_used_at을 갱신한다."""
241
+ projects = _load_projects()
242
+ for p in projects:
243
+ if p["id"] == project_id:
244
+ p["last_used_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
245
+ _save_projects(projects)
246
+ return
@@ -0,0 +1,141 @@
1
+ /* ═══════════════════════════════════════════════
2
+ API & Service — 백엔드 연결, API 호출, 서비스 상태
3
+ ═══════════════════════════════════════════════ */
4
+
5
+ const LOCAL_BACKEND = 'http://localhost:8420';
6
+ let API = '';
7
+ let AUTH_TOKEN = '';
8
+ let _backendConnected = false;
9
+ let serviceRunning = null;
10
+
11
+ /* ── Connection health tracking ── */
12
+ let _connFailCount = 0;
13
+ const _CONN_FAIL_THRESHOLD = 3;
14
+ let _connBannerVisible = false;
15
+
16
+ function _updateConnBanner(ok) {
17
+ if (ok) {
18
+ if (_connFailCount > 0) _connFailCount = 0;
19
+ if (_connBannerVisible) {
20
+ _connBannerVisible = false;
21
+ const banner = document.getElementById('connLostBanner');
22
+ if (banner) banner.classList.remove('visible');
23
+ }
24
+ } else {
25
+ _connFailCount++;
26
+ if (_connFailCount >= _CONN_FAIL_THRESHOLD && !_connBannerVisible) {
27
+ _connBannerVisible = true;
28
+ const banner = document.getElementById('connLostBanner');
29
+ if (banner) banner.classList.add('visible');
30
+ }
31
+ }
32
+ }
33
+
34
+ async function apiFetch(path, options = {}) {
35
+ const headers = { 'Content-Type': 'application/json', ...options.headers };
36
+ if (AUTH_TOKEN) {
37
+ headers['Authorization'] = `Bearer ${AUTH_TOKEN}`;
38
+ }
39
+ let resp;
40
+ try {
41
+ resp = await fetch(`${API}${path}`, { ...options, headers });
42
+ } catch (networkErr) {
43
+ _updateConnBanner(false);
44
+ throw networkErr;
45
+ }
46
+ _updateConnBanner(true);
47
+ if (!resp.ok) {
48
+ let msg = '';
49
+ try {
50
+ const ct = resp.headers.get('content-type') || '';
51
+ if (ct.includes('application/json')) {
52
+ const body = await resp.json();
53
+ if (body.error && typeof body.error === 'object') {
54
+ msg = body.error.message || JSON.stringify(body.error);
55
+ } else {
56
+ msg = body.error || body.message || JSON.stringify(body);
57
+ }
58
+ } else {
59
+ msg = await resp.text();
60
+ }
61
+ } catch { /* parse fail — fall through */ }
62
+ throw new Error(msg || `HTTP ${resp.status}`);
63
+ }
64
+ const ct = resp.headers.get('content-type') || '';
65
+ if (ct.includes('application/json')) return resp.json();
66
+ return resp.text();
67
+ }
68
+
69
+ async function checkStatus() {
70
+ try {
71
+ const data = await apiFetch('/api/status');
72
+ const running = data.running !== undefined ? data.running : (data.status === 'running');
73
+ serviceRunning = running;
74
+ } catch {
75
+ serviceRunning = null;
76
+ }
77
+ }
78
+
79
+ /* ── Goals API ── */
80
+ async function fetchGoals(status) {
81
+ const qs = status ? `?status=${status}` : '';
82
+ return apiFetch(`/api/goals${qs}`);
83
+ }
84
+ async function createGoal(objective, mode = 'gate', opts = {}) {
85
+ return apiFetch('/api/goals', {
86
+ method: 'POST',
87
+ body: JSON.stringify({ objective, mode, ...opts }),
88
+ });
89
+ }
90
+ async function getGoal(id) { return apiFetch(`/api/goals/${id}`); }
91
+ async function updateGoal(id, fields) {
92
+ return apiFetch(`/api/goals/${id}/update`, {
93
+ method: 'POST', body: JSON.stringify(fields),
94
+ });
95
+ }
96
+ async function approveGoal(id) {
97
+ return apiFetch(`/api/goals/${id}/approve`, { method: 'POST' });
98
+ }
99
+ async function cancelGoal(id) {
100
+ return apiFetch(`/api/goals/${id}`, { method: 'DELETE' });
101
+ }
102
+
103
+ /* ── Memory API ── */
104
+ async function fetchMemories(params = {}) {
105
+ const qs = new URLSearchParams(params).toString();
106
+ return apiFetch(`/api/memory${qs ? '?' + qs : ''}`);
107
+ }
108
+ async function createMemory(data) {
109
+ return apiFetch('/api/memory', {
110
+ method: 'POST', body: JSON.stringify(data),
111
+ });
112
+ }
113
+ async function getMemory(id) { return apiFetch(`/api/memory/${id}`); }
114
+ async function updateMemory(id, fields) {
115
+ return apiFetch(`/api/memory/${id}/update`, {
116
+ method: 'POST', body: JSON.stringify(fields),
117
+ });
118
+ }
119
+ async function deleteMemory(id) {
120
+ return apiFetch(`/api/memory/${id}`, { method: 'DELETE' });
121
+ }
122
+
123
+ async function serviceAction(action) {
124
+ const btn = document.getElementById(`btn${action.charAt(0).toUpperCase() + action.slice(1)}`);
125
+ if (btn) btn.disabled = true;
126
+ try {
127
+ if (action === 'restart') {
128
+ await apiFetch('/api/service/stop', { method: 'POST' });
129
+ await new Promise(r => setTimeout(r, 500));
130
+ await apiFetch('/api/service/start', { method: 'POST' });
131
+ } else {
132
+ await apiFetch(`/api/service/${action}`, { method: 'POST' });
133
+ }
134
+ showToast(t(action === 'start' ? 'msg_service_start' : action === 'stop' ? 'msg_service_stop' : 'msg_service_restart'));
135
+ setTimeout(checkStatus, 1000);
136
+ } catch (err) {
137
+ showToast(`${t('msg_service_failed')}: ${err.message}`, 'error');
138
+ } finally {
139
+ if (btn) btn.disabled = false;
140
+ }
141
+ }