@purpleraven/hits 0.2.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 (58) hide show
  1. package/AGENTS.md +298 -0
  2. package/LICENSE +190 -0
  3. package/README.md +336 -0
  4. package/bin/hits.js +56 -0
  5. package/config/schema.json +94 -0
  6. package/config/settings.yaml +102 -0
  7. package/data/dev_handover.yaml +143 -0
  8. package/hits_core/__init__.py +9 -0
  9. package/hits_core/ai/__init__.py +11 -0
  10. package/hits_core/ai/compressor.py +86 -0
  11. package/hits_core/ai/llm_client.py +65 -0
  12. package/hits_core/ai/slm_filter.py +126 -0
  13. package/hits_core/api/__init__.py +3 -0
  14. package/hits_core/api/routes/__init__.py +8 -0
  15. package/hits_core/api/routes/auth.py +211 -0
  16. package/hits_core/api/routes/handover.py +117 -0
  17. package/hits_core/api/routes/health.py +8 -0
  18. package/hits_core/api/routes/knowledge.py +177 -0
  19. package/hits_core/api/routes/node.py +121 -0
  20. package/hits_core/api/routes/work_log.py +174 -0
  21. package/hits_core/api/server.py +181 -0
  22. package/hits_core/auth/__init__.py +21 -0
  23. package/hits_core/auth/dependencies.py +61 -0
  24. package/hits_core/auth/manager.py +368 -0
  25. package/hits_core/auth/middleware.py +69 -0
  26. package/hits_core/collector/__init__.py +18 -0
  27. package/hits_core/collector/ai_session_collector.py +118 -0
  28. package/hits_core/collector/base.py +73 -0
  29. package/hits_core/collector/daemon.py +94 -0
  30. package/hits_core/collector/git_collector.py +177 -0
  31. package/hits_core/collector/hits_action_collector.py +110 -0
  32. package/hits_core/collector/shell_collector.py +178 -0
  33. package/hits_core/main.py +36 -0
  34. package/hits_core/mcp/__init__.py +20 -0
  35. package/hits_core/mcp/server.py +429 -0
  36. package/hits_core/models/__init__.py +18 -0
  37. package/hits_core/models/node.py +56 -0
  38. package/hits_core/models/tree.py +68 -0
  39. package/hits_core/models/work_log.py +64 -0
  40. package/hits_core/models/workflow.py +92 -0
  41. package/hits_core/platform/__init__.py +5 -0
  42. package/hits_core/platform/actions.py +225 -0
  43. package/hits_core/service/__init__.py +6 -0
  44. package/hits_core/service/handover_service.py +382 -0
  45. package/hits_core/service/knowledge_service.py +172 -0
  46. package/hits_core/service/tree_service.py +105 -0
  47. package/hits_core/storage/__init__.py +11 -0
  48. package/hits_core/storage/base.py +84 -0
  49. package/hits_core/storage/file_store.py +314 -0
  50. package/hits_core/storage/redis_store.py +123 -0
  51. package/hits_web/dist/assets/index-Bgx7F6m6.css +1 -0
  52. package/hits_web/dist/assets/index-D1B5E67G.js +3 -0
  53. package/hits_web/dist/index.html +16 -0
  54. package/package.json +60 -0
  55. package/requirements-core.txt +7 -0
  56. package/requirements.txt +1 -0
  57. package/run.sh +271 -0
  58. package/server.js +234 -0
@@ -0,0 +1,84 @@
1
+ """Storage abstraction layer."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Optional
5
+ from datetime import datetime
6
+ from ..models.tree import KnowledgeTree
7
+ from ..models.workflow import Workflow
8
+ from ..models.work_log import WorkLog
9
+
10
+
11
+ class BaseStorage(ABC):
12
+ @abstractmethod
13
+ async def save_tree(self, tree: KnowledgeTree) -> bool:
14
+ pass
15
+
16
+ @abstractmethod
17
+ async def load_tree(self, tree_id: str) -> Optional[KnowledgeTree]:
18
+ pass
19
+
20
+ @abstractmethod
21
+ async def delete_tree(self, tree_id: str) -> bool:
22
+ pass
23
+
24
+ @abstractmethod
25
+ async def list_trees(self) -> list[str]:
26
+ pass
27
+
28
+ @abstractmethod
29
+ async def save_workflow(self, workflow: Workflow) -> bool:
30
+ pass
31
+
32
+ @abstractmethod
33
+ async def load_workflow(self, workflow_id: str) -> Optional[Workflow]:
34
+ pass
35
+
36
+ @abstractmethod
37
+ async def delete_workflow(self, workflow_id: str) -> bool:
38
+ pass
39
+
40
+ @abstractmethod
41
+ async def list_workflows(self) -> list[str]:
42
+ pass
43
+
44
+ @abstractmethod
45
+ async def save_work_log(self, log: WorkLog) -> bool:
46
+ pass
47
+
48
+ @abstractmethod
49
+ async def load_work_log(self, log_id: str) -> Optional[WorkLog]:
50
+ pass
51
+
52
+ @abstractmethod
53
+ async def delete_work_log(self, log_id: str) -> bool:
54
+ pass
55
+
56
+ @abstractmethod
57
+ async def list_work_logs(
58
+ self,
59
+ performed_by: Optional[str] = None,
60
+ source: Optional[str] = None,
61
+ since: Optional[datetime] = None,
62
+ project_path: Optional[str] = None,
63
+ limit: int = 100,
64
+ ) -> list[WorkLog]:
65
+ pass
66
+
67
+ @abstractmethod
68
+ async def search_work_logs(
69
+ self,
70
+ query: str,
71
+ project_path: Optional[str] = None,
72
+ limit: int = 50,
73
+ ) -> list[WorkLog]:
74
+ pass
75
+
76
+ @abstractmethod
77
+ async def list_project_paths(self) -> list[str]:
78
+ """Return all unique project paths that have work logs."""
79
+ pass
80
+
81
+ @abstractmethod
82
+ async def get_project_summary(self, project_path: str) -> dict:
83
+ """Get aggregated statistics for a specific project."""
84
+ pass
@@ -0,0 +1,314 @@
1
+ """File-based storage backend for fallback and local development."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ from datetime import datetime
8
+
9
+ from .base import BaseStorage
10
+ from ..models.tree import KnowledgeTree
11
+ from ..models.workflow import Workflow
12
+ from ..models.work_log import WorkLog
13
+
14
+
15
+ class FileStorage(BaseStorage):
16
+ TREE_DIR = "trees"
17
+ WORKFLOW_DIR = "workflows"
18
+ WORK_LOG_DIR = "work_logs"
19
+ INDEX_FILE = "index.json"
20
+
21
+ # Centralized data home - all AI tools write to the same location
22
+ DEFAULT_DATA_HOME = Path.home() / ".hits" / "data"
23
+
24
+ def __init__(self, base_path: Optional[str] = None):
25
+ if base_path:
26
+ self.base_path = Path(base_path)
27
+ else:
28
+ # Priority: env var > ~/.hits/data/ (centralized)
29
+ env_path = os.environ.get("HITS_DATA_PATH")
30
+ if env_path:
31
+ self.base_path = Path(env_path)
32
+ else:
33
+ self.base_path = self.DEFAULT_DATA_HOME
34
+
35
+ self.tree_dir = self.base_path / self.TREE_DIR
36
+ self.workflow_dir = self.base_path / self.WORKFLOW_DIR
37
+ self.work_log_dir = self.base_path / self.WORK_LOG_DIR
38
+
39
+ self.tree_dir.mkdir(parents=True, exist_ok=True)
40
+ self.workflow_dir.mkdir(parents=True, exist_ok=True)
41
+ self.work_log_dir.mkdir(parents=True, exist_ok=True)
42
+
43
+ def _tree_path(self, tree_id: str) -> Path:
44
+ return self.tree_dir / f"{tree_id}.json"
45
+
46
+ def _workflow_path(self, workflow_id: str) -> Path:
47
+ return self.workflow_dir / f"{workflow_id}.json"
48
+
49
+ def _work_log_path(self, log_id: str) -> Path:
50
+ return self.work_log_dir / f"{log_id}.json"
51
+
52
+ def _index_path(self, dir_path: Path) -> Path:
53
+ return dir_path / self.INDEX_FILE
54
+
55
+ def _read_index(self, dir_path: Path) -> list[str]:
56
+ index_file = self._index_path(dir_path)
57
+ if index_file.exists():
58
+ with open(index_file, "r", encoding="utf-8") as f:
59
+ return json.load(f)
60
+ return []
61
+
62
+ def _write_index(self, dir_path: Path, items: list[str]) -> None:
63
+ index_file = self._index_path(dir_path)
64
+ with open(index_file, "w", encoding="utf-8") as f:
65
+ json.dump(items, f)
66
+
67
+ async def save_tree(self, tree: KnowledgeTree) -> bool:
68
+ try:
69
+ path = self._tree_path(tree.id)
70
+ with open(path, "w", encoding="utf-8") as f:
71
+ f.write(tree.model_dump_json(indent=2))
72
+
73
+ index = self._read_index(self.tree_dir)
74
+ if tree.id not in index:
75
+ index.append(tree.id)
76
+ self._write_index(self.tree_dir, index)
77
+
78
+ return True
79
+ except Exception:
80
+ return False
81
+
82
+ async def load_tree(self, tree_id: str) -> Optional[KnowledgeTree]:
83
+ try:
84
+ path = self._tree_path(tree_id)
85
+ if not path.exists():
86
+ return None
87
+ with open(path, "r", encoding="utf-8") as f:
88
+ return KnowledgeTree.model_validate_json(f.read())
89
+ except Exception:
90
+ return None
91
+
92
+ async def delete_tree(self, tree_id: str) -> bool:
93
+ try:
94
+ path = self._tree_path(tree_id)
95
+ if path.exists():
96
+ path.unlink()
97
+
98
+ index = self._read_index(self.tree_dir)
99
+ if tree_id in index:
100
+ index.remove(tree_id)
101
+ self._write_index(self.tree_dir, index)
102
+
103
+ return True
104
+ except Exception:
105
+ return False
106
+
107
+ async def list_trees(self) -> list[str]:
108
+ return self._read_index(self.tree_dir)
109
+
110
+ async def save_workflow(self, workflow: Workflow) -> bool:
111
+ try:
112
+ path = self._workflow_path(workflow.id)
113
+ with open(path, "w", encoding="utf-8") as f:
114
+ f.write(workflow.model_dump_json(indent=2))
115
+
116
+ index = self._read_index(self.workflow_dir)
117
+ if workflow.id not in index:
118
+ index.append(workflow.id)
119
+ self._write_index(self.workflow_dir, index)
120
+
121
+ return True
122
+ except Exception:
123
+ return False
124
+
125
+ async def load_workflow(self, workflow_id: str) -> Optional[Workflow]:
126
+ try:
127
+ path = self._workflow_path(workflow_id)
128
+ if not path.exists():
129
+ return None
130
+ with open(path, "r", encoding="utf-8") as f:
131
+ return Workflow.model_validate_json(f.read())
132
+ except Exception:
133
+ return None
134
+
135
+ async def delete_workflow(self, workflow_id: str) -> bool:
136
+ try:
137
+ path = self._workflow_path(workflow_id)
138
+ if path.exists():
139
+ path.unlink()
140
+
141
+ index = self._read_index(self.workflow_dir)
142
+ if workflow_id in index:
143
+ index.remove(workflow_id)
144
+ self._write_index(self.workflow_dir, index)
145
+
146
+ return True
147
+ except Exception:
148
+ return False
149
+
150
+ async def list_workflows(self) -> list[str]:
151
+ return self._read_index(self.workflow_dir)
152
+
153
+ async def save_work_log(self, log: WorkLog) -> bool:
154
+ try:
155
+ path = self._work_log_path(log.id)
156
+ with open(path, "w", encoding="utf-8") as f:
157
+ f.write(log.model_dump_json(indent=2))
158
+
159
+ index = self._read_index(self.work_log_dir)
160
+ if log.id not in index:
161
+ index.append(log.id)
162
+ self._write_index(self.work_log_dir, index)
163
+
164
+ return True
165
+ except Exception:
166
+ return False
167
+
168
+ async def load_work_log(self, log_id: str) -> Optional[WorkLog]:
169
+ try:
170
+ path = self._work_log_path(log_id)
171
+ if not path.exists():
172
+ return None
173
+ with open(path, "r", encoding="utf-8") as f:
174
+ return WorkLog.model_validate_json(f.read())
175
+ except Exception:
176
+ return None
177
+
178
+ async def delete_work_log(self, log_id: str) -> bool:
179
+ try:
180
+ path = self._work_log_path(log_id)
181
+ if path.exists():
182
+ path.unlink()
183
+
184
+ index = self._read_index(self.work_log_dir)
185
+ if log_id in index:
186
+ index.remove(log_id)
187
+ self._write_index(self.work_log_dir, index)
188
+
189
+ return True
190
+ except Exception:
191
+ return False
192
+
193
+ async def list_work_logs(
194
+ self,
195
+ performed_by: Optional[str] = None,
196
+ source: Optional[str] = None,
197
+ since: Optional[datetime] = None,
198
+ project_path: Optional[str] = None,
199
+ limit: int = 100,
200
+ ) -> list[WorkLog]:
201
+ logs = []
202
+ index = self._read_index(self.work_log_dir)
203
+
204
+ for log_id in index:
205
+ log = await self.load_work_log(log_id)
206
+ if log is None:
207
+ continue
208
+
209
+ if performed_by and log.performed_by != performed_by:
210
+ continue
211
+ if source and log.source != source:
212
+ continue
213
+ if since and log.performed_at < since:
214
+ continue
215
+ if project_path and log.project_path != project_path:
216
+ continue
217
+
218
+ logs.append(log)
219
+
220
+ logs.sort(key=lambda x: x.performed_at, reverse=True)
221
+ return logs[:limit]
222
+
223
+ async def search_work_logs(
224
+ self,
225
+ query: str,
226
+ project_path: Optional[str] = None,
227
+ limit: int = 50,
228
+ ) -> list[WorkLog]:
229
+ query_lower = query.lower()
230
+ logs = []
231
+ index = self._read_index(self.work_log_dir)
232
+
233
+ for log_id in index:
234
+ log = await self.load_work_log(log_id)
235
+ if log is None:
236
+ continue
237
+
238
+ # Project filter first (narrow down before text search)
239
+ if project_path and log.project_path != project_path:
240
+ continue
241
+
242
+ searchable = " ".join([
243
+ log.request_text or "",
244
+ log.context or "",
245
+ " ".join(log.tags),
246
+ log.result_ref or "",
247
+ log.performed_by,
248
+ log.category or "",
249
+ log.project_path or "",
250
+ ]).lower()
251
+
252
+ if query_lower in searchable:
253
+ logs.append(log)
254
+
255
+ logs.sort(key=lambda x: x.performed_at, reverse=True)
256
+ return logs[:limit]
257
+
258
+ async def list_project_paths(self) -> list[str]:
259
+ """Return all unique project paths that have work logs."""
260
+ paths: set[str] = set()
261
+ index = self._read_index(self.work_log_dir)
262
+
263
+ for log_id in index:
264
+ log = await self.load_work_log(log_id)
265
+ if log and log.project_path:
266
+ paths.add(log.project_path)
267
+
268
+ return sorted(paths)
269
+
270
+ async def get_project_summary(self, project_path: str) -> dict:
271
+ """Get aggregated statistics for a specific project."""
272
+ index = self._read_index(self.work_log_dir)
273
+
274
+ total_logs = 0
275
+ ai_sessions = 0
276
+ files_modified: set[str] = set()
277
+ commands_run: list[str] = []
278
+ tags: dict[str, int] = {}
279
+ performers: dict[str, int] = {}
280
+ last_activity: Optional[datetime] = None
281
+
282
+ for log_id in index:
283
+ log = await self.load_work_log(log_id)
284
+ if log is None or log.project_path != project_path:
285
+ continue
286
+
287
+ total_logs += 1
288
+
289
+ if log.source == "ai_session":
290
+ ai_sessions += 1
291
+
292
+ if log.result_data:
293
+ for f in log.result_data.get("files_modified", []):
294
+ files_modified.add(f)
295
+ commands_run.extend(log.result_data.get("commands_run", []))
296
+
297
+ for tag in log.tags:
298
+ tags[tag] = tags.get(tag, 0) + 1
299
+
300
+ performers[log.performed_by] = performers.get(log.performed_by, 0) + 1
301
+
302
+ if last_activity is None or log.performed_at > last_activity:
303
+ last_activity = log.performed_at
304
+
305
+ return {
306
+ "project_path": project_path,
307
+ "total_logs": total_logs,
308
+ "ai_sessions": ai_sessions,
309
+ "files_modified": sorted(files_modified),
310
+ "commands_run": commands_run,
311
+ "tags": dict(sorted(tags.items(), key=lambda x: x[1], reverse=True)),
312
+ "performers": performers,
313
+ "last_activity": last_activity.isoformat() if last_activity else None,
314
+ }
@@ -0,0 +1,123 @@
1
+ """Redis storage backend with ReJSON support."""
2
+
3
+ import json
4
+ from typing import Optional
5
+ import redis.asyncio as redis
6
+ from redis.asyncio.connection import ConnectionPool
7
+
8
+ from .base import BaseStorage
9
+ from ..models.tree import KnowledgeTree
10
+ from ..models.workflow import Workflow
11
+
12
+
13
+ class RedisStorage(BaseStorage):
14
+ TREE_PREFIX = "hits:tree:"
15
+ WORKFLOW_PREFIX = "hits:workflow:"
16
+ TREE_LIST_KEY = "hits:trees"
17
+ WORKFLOW_LIST_KEY = "hits:workflows"
18
+
19
+ def __init__(
20
+ self,
21
+ host: str = "localhost",
22
+ port: int = 6379,
23
+ db: int = 0,
24
+ password: Optional[str] = None,
25
+ pool: Optional[ConnectionPool] = None,
26
+ ):
27
+ if pool:
28
+ self.client = redis.Redis(connection_pool=pool)
29
+ else:
30
+ self.client = redis.Redis(
31
+ host=host,
32
+ port=port,
33
+ db=db,
34
+ password=password,
35
+ decode_responses=True,
36
+ )
37
+
38
+ def _tree_key(self, tree_id: str) -> str:
39
+ return f"{self.TREE_PREFIX}{tree_id}"
40
+
41
+ def _workflow_key(self, workflow_id: str) -> str:
42
+ return f"{self.WORKFLOW_PREFIX}{workflow_id}"
43
+
44
+ async def ping(self) -> bool:
45
+ try:
46
+ return await self.client.ping()
47
+ except redis.ConnectionError:
48
+ return False
49
+
50
+ async def save_tree(self, tree: KnowledgeTree) -> bool:
51
+ try:
52
+ key = self._tree_key(tree.id)
53
+ data = tree.model_dump_json()
54
+ await self.client.set(key, data)
55
+ await self.client.sadd(self.TREE_LIST_KEY, tree.id)
56
+ return True
57
+ except Exception:
58
+ return False
59
+
60
+ async def load_tree(self, tree_id: str) -> Optional[KnowledgeTree]:
61
+ try:
62
+ key = self._tree_key(tree_id)
63
+ data = await self.client.get(key)
64
+ if not data:
65
+ return None
66
+ return KnowledgeTree.model_validate_json(data)
67
+ except Exception:
68
+ return None
69
+
70
+ async def delete_tree(self, tree_id: str) -> bool:
71
+ try:
72
+ key = self._tree_key(tree_id)
73
+ await self.client.delete(key)
74
+ await self.client.srem(self.TREE_LIST_KEY, tree_id)
75
+ return True
76
+ except Exception:
77
+ return False
78
+
79
+ async def list_trees(self) -> list[str]:
80
+ try:
81
+ members = await self.client.smembers(self.TREE_LIST_KEY)
82
+ return list(members) if members else []
83
+ except Exception:
84
+ return []
85
+
86
+ async def save_workflow(self, workflow: Workflow) -> bool:
87
+ try:
88
+ key = self._workflow_key(workflow.id)
89
+ data = workflow.model_dump_json()
90
+ await self.client.set(key, data)
91
+ await self.client.sadd(self.WORKFLOW_LIST_KEY, workflow.id)
92
+ return True
93
+ except Exception:
94
+ return False
95
+
96
+ async def load_workflow(self, workflow_id: str) -> Optional[Workflow]:
97
+ try:
98
+ key = self._workflow_key(workflow_id)
99
+ data = await self.client.get(key)
100
+ if not data:
101
+ return None
102
+ return Workflow.model_validate_json(data)
103
+ except Exception:
104
+ return None
105
+
106
+ async def delete_workflow(self, workflow_id: str) -> bool:
107
+ try:
108
+ key = self._workflow_key(workflow_id)
109
+ await self.client.delete(key)
110
+ await self.client.srem(self.WORKFLOW_LIST_KEY, workflow_id)
111
+ return True
112
+ except Exception:
113
+ return False
114
+
115
+ async def list_workflows(self) -> list[str]:
116
+ try:
117
+ members = await self.client.smembers(self.WORKFLOW_LIST_KEY)
118
+ return list(members) if members else []
119
+ except Exception:
120
+ return []
121
+
122
+ async def close(self) -> None:
123
+ await self.client.aclose()
@@ -0,0 +1 @@
1
+ :root{--bg-primary: #0F0F1A;--bg-secondary: #1A1A2E;--bg-surface1: #1E1E32;--bg-surface2: #252540;--bg-surface3: #2D2D4A;--bg-hover: #333355;--bg-active: #3D3D60;--text-primary: #E8E8F0;--text-secondary: #A0A0B8;--text-muted: #707088;--accent-primary: #1565C0;--accent-secondary: #42A5F5;--accent-hover: #1976D2;--border-color: #2A2A44;--divider: #333355;--layer-why: #FF9800;--layer-how: #4CAF50;--layer-what: #2196F3;--success: #4CAF50;--danger: #F44336;--warning: #FF9800;--info: #2196F3;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow-md: 0 4px 12px rgba(0,0,0,.4);--shadow-lg: 0 8px 24px rgba(0,0,0,.5);--radius-sm: 4px;--radius-md: 8px;--radius-lg: 12px;--font-sans: "Noto Sans KR", "Segoe UI", system-ui, -apple-system, sans-serif;--font-mono: "Fira Code", "Consolas", monospace}*{margin:0;padding:0;box-sizing:border-box}html,body,#app{height:100%;width:100%;overflow:hidden}body{font-family:var(--font-sans);background:var(--bg-primary);color:var(--text-primary);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:var(--bg-secondary)}::-webkit-scrollbar-thumb{background:var(--bg-surface3);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:var(--bg-hover)}.flex{display:flex}.flex-col{flex-direction:column}.items-center{align-items:center}.gap-sm{gap:8px}.gap-md{gap:16px}.gap-lg{gap:24px}.w-full{width:100%}.h-full{height:100%}.overflow-y{overflow-y:auto}.text-muted{color:var(--text-muted)}.text-sm{font-size:12px}.text-xs{font-size:11px}.mt-sm{margin-top:8px}.mt-md{margin-top:16px}.mb-sm{margin-bottom:8px}.p-sm{padding:8px}.p-md{padding:16px}.app-layout{display:flex;height:100vh;width:100vw}.sidebar{width:260px;min-width:260px;background:var(--bg-secondary);border-right:1px solid var(--border-color);display:flex;flex-direction:column;transition:width .2s,min-width .2s}.sidebar.collapsed{width:0;min-width:0;overflow:hidden}.main-content{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}.header{height:52px;background:linear-gradient(135deg,var(--bg-surface1),var(--bg-surface2));border-bottom:1px solid var(--border-color);display:flex;align-items:center;padding:0 16px;gap:12px;flex-shrink:0}.header h1{font-size:16px;font-weight:600;background:linear-gradient(90deg,var(--accent-secondary),#90CAF9);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.tabs{display:flex;gap:2px;background:var(--bg-secondary);padding:4px 12px;border-bottom:1px solid var(--border-color);flex-shrink:0}.tab{padding:8px 16px;background:transparent;color:var(--text-secondary);border:none;border-radius:var(--radius-sm) var(--radius-sm) 0 0;cursor:pointer;font-size:13px;font-family:var(--font-sans);transition:all .15s}.tab:hover{background:var(--bg-surface1);color:var(--text-primary)}.tab.active{background:var(--bg-primary);color:var(--accent-secondary);border-bottom:2px solid var(--accent-primary)}.btn{padding:8px 16px;border:none;border-radius:var(--radius-sm);cursor:pointer;font-size:13px;font-family:var(--font-sans);font-weight:500;transition:all .15s;display:inline-flex;align-items:center;gap:6px}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background:var(--accent-primary);color:#fff}.btn-primary:hover:not(:disabled){background:var(--accent-hover)}.btn-secondary{background:var(--bg-surface2);color:var(--text-primary);border:1px solid var(--border-color)}.btn-secondary:hover:not(:disabled){background:var(--bg-surface3)}.btn-danger{background:var(--danger);color:#fff}.btn-danger:hover:not(:disabled){background:#d32f2f}.btn-sm{padding:4px 10px;font-size:12px}.btn-icon{width:32px;height:32px;padding:0;display:flex;align-items:center;justify-content:center;background:var(--bg-surface2);color:var(--text-secondary);border:1px solid var(--border-color);border-radius:var(--radius-sm);cursor:pointer;font-size:16px}.btn-icon:hover{background:var(--bg-surface3);color:var(--text-primary)}.input{width:100%;padding:8px 12px;background:var(--bg-surface1);color:var(--text-primary);border:1px solid var(--border-color);border-radius:var(--radius-sm);font-size:13px;font-family:var(--font-sans);outline:none;transition:border-color .15s}.input:focus{border-color:var(--accent-primary)}.input::placeholder{color:var(--text-muted)}textarea.input{resize:vertical;min-height:60px}select.input{cursor:pointer}.card{background:var(--bg-surface1);border:1px solid var(--border-color);border-radius:var(--radius-md);padding:12px 16px;transition:border-color .15s,box-shadow .15s}.card:hover{border-color:var(--bg-surface3);box-shadow:var(--shadow-sm)}.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500}.badge-why{background:#ff980026;color:var(--layer-why)}.badge-how{background:#4caf5026;color:var(--layer-how)}.badge-what{background:#2196f326;color:var(--layer-what)}.modal-overlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#0009;display:flex;align-items:center;justify-content:center;z-index:1000}.modal{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-lg);padding:24px;width:90%;max-width:480px;max-height:80vh;overflow-y:auto;box-shadow:var(--shadow-lg)}.modal h2{font-size:16px;margin-bottom:16px;color:var(--text-primary)}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:linear-gradient(135deg,#0a0a1a,#1a1a3e)}.login-card{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-lg);padding:32px;width:90%;max-width:400px;box-shadow:var(--shadow-lg)}.login-card h1{text-align:center;margin-bottom:8px;font-size:20px}.login-card .subtitle{text-align:center;color:var(--text-muted);margin-bottom:24px;font-size:13px}.form-group{margin-bottom:16px}.form-group label{display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500}.error-msg{color:var(--danger);font-size:12px;margin-top:8px}.sidebar-section{padding:12px;border-bottom:1px solid var(--border-color)}.sidebar-section h3{font-size:11px;text-transform:uppercase;color:var(--text-muted);letter-spacing:.5px;margin-bottom:8px}.project-item{padding:6px 8px;border-radius:var(--radius-sm);cursor:pointer;font-size:12px;color:var(--text-secondary);transition:all .1s;display:flex;align-items:center;gap:6px}.project-item:hover{background:var(--bg-surface2);color:var(--text-primary)}.project-item.active{background:var(--accent-primary);color:#fff}.category-header{display:flex;align-items:center;padding:10px 16px;background:var(--bg-surface1);border:1px solid var(--border-color);border-radius:var(--radius-md);cursor:pointer;gap:8px;transition:background .1s;margin-bottom:4px}.category-header:hover{background:var(--bg-surface2)}.category-header .icon{font-size:18px}.category-header .name{flex:1;font-size:13px;font-weight:500}.category-header .count{font-size:11px;color:var(--text-muted)}.category-header .actions{display:flex;gap:4px;opacity:0;transition:opacity .15s}.category-header:hover .actions{opacity:1}.node-item{display:flex;align-items:center;padding:8px 16px 8px 36px;gap:8px;border-left:2px solid transparent;transition:all .1s}.node-item:hover{background:var(--bg-surface1)}.node-item.layer-why{border-left-color:var(--layer-why)}.node-item.layer-how{border-left-color:var(--layer-how)}.node-item.layer-what{border-left-color:var(--layer-what)}.node-item .node-name{flex:1;font-size:13px}.node-item .node-action{font-size:11px;color:var(--text-muted);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.node-item.negative-path{opacity:.7}.node-item.negative-path:before{content:"⚠";font-size:12px}.timeline-item{padding:12px 16px;border-bottom:1px solid var(--border-color);transition:background .1s;cursor:pointer}.timeline-item:hover{background:var(--bg-surface1)}.timeline-date{padding:8px 16px;font-size:12px;font-weight:600;color:var(--text-muted);background:var(--bg-secondary);position:sticky;top:0;z-index:10;border-bottom:1px solid var(--border-color)}.timeline-item .meta{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--text-muted);margin-top:4px}.timeline-item .summary{font-size:13px;color:var(--text-primary)}.timeline-item .tags{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap}.tag{padding:1px 6px;background:var(--bg-surface3);border-radius:8px;font-size:10px;color:var(--text-secondary)}.handover-section{margin-bottom:20px}.handover-section h3{font-size:14px;font-weight:600;margin-bottom:8px;color:var(--accent-secondary)}.handover-item{padding:6px 12px;border-left:2px solid var(--divider);margin-left:8px;font-size:13px;color:var(--text-secondary);margin-bottom:4px}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:48px;color:var(--text-muted)}.empty-state .icon{font-size:48px;margin-bottom:16px}.empty-state .message{font-size:14px;margin-bottom:16px}.loading{display:flex;align-items:center;justify-content:center;padding:24px;color:var(--text-muted)}.spinner{width:20px;height:20px;border:2px solid var(--bg-surface3);border-top:2px solid var(--accent-secondary);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.user-menu{position:relative;margin-left:auto}.user-menu-toggle{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:var(--radius-sm);cursor:pointer;color:var(--text-secondary);font-size:13px;background:none;border:none;font-family:var(--font-sans)}.user-menu-toggle:hover{background:var(--bg-surface2);color:var(--text-primary)}.user-dropdown{position:absolute;top:100%;right:0;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-md);padding:4px;min-width:160px;box-shadow:var(--shadow-md);z-index:100}.user-dropdown button{display:block;width:100%;text-align:left;padding:8px 12px;background:none;border:none;color:var(--text-secondary);font-size:13px;font-family:var(--font-sans);cursor:pointer;border-radius:var(--radius-sm)}.user-dropdown button:hover{background:var(--bg-surface2);color:var(--text-primary)}.content-area{flex:1;overflow-y:auto;padding:16px}