devsquad 3.6.0__py3-none-any.whl
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.
- devsquad-3.6.0.dist-info/METADATA +944 -0
- devsquad-3.6.0.dist-info/RECORD +95 -0
- devsquad-3.6.0.dist-info/WHEEL +5 -0
- devsquad-3.6.0.dist-info/entry_points.txt +2 -0
- devsquad-3.6.0.dist-info/licenses/LICENSE +21 -0
- devsquad-3.6.0.dist-info/top_level.txt +2 -0
- scripts/__init__.py +0 -0
- scripts/ai_semantic_matcher.py +512 -0
- scripts/alert_manager.py +505 -0
- scripts/api/__init__.py +43 -0
- scripts/api/models.py +386 -0
- scripts/api/routes/__init__.py +20 -0
- scripts/api/routes/dispatch.py +348 -0
- scripts/api/routes/lifecycle.py +330 -0
- scripts/api/routes/metrics_gates.py +347 -0
- scripts/api_server.py +318 -0
- scripts/auth.py +451 -0
- scripts/cli/__init__.py +1 -0
- scripts/cli/cli_visual.py +642 -0
- scripts/cli.py +1094 -0
- scripts/collaboration/__init__.py +212 -0
- scripts/collaboration/_version.py +1 -0
- scripts/collaboration/agent_briefing.py +656 -0
- scripts/collaboration/ai_semantic_matcher.py +260 -0
- scripts/collaboration/anchor_checker.py +281 -0
- scripts/collaboration/anti_rationalization.py +470 -0
- scripts/collaboration/async_integration_example.py +255 -0
- scripts/collaboration/batch_scheduler.py +149 -0
- scripts/collaboration/checkpoint_manager.py +561 -0
- scripts/collaboration/ci_feedback_adapter.py +351 -0
- scripts/collaboration/code_map_generator.py +247 -0
- scripts/collaboration/concern_pack_loader.py +352 -0
- scripts/collaboration/confidence_score.py +496 -0
- scripts/collaboration/config_loader.py +188 -0
- scripts/collaboration/consensus.py +244 -0
- scripts/collaboration/context_compressor.py +533 -0
- scripts/collaboration/coordinator.py +668 -0
- scripts/collaboration/dispatcher.py +1636 -0
- scripts/collaboration/dual_layer_context.py +128 -0
- scripts/collaboration/enhanced_worker.py +539 -0
- scripts/collaboration/feature_usage_tracker.py +206 -0
- scripts/collaboration/five_axis_consensus.py +334 -0
- scripts/collaboration/input_validator.py +401 -0
- scripts/collaboration/integration_example.py +287 -0
- scripts/collaboration/intent_workflow_mapper.py +350 -0
- scripts/collaboration/language_parsers.py +269 -0
- scripts/collaboration/lifecycle_protocol.py +1446 -0
- scripts/collaboration/llm_backend.py +453 -0
- scripts/collaboration/llm_cache.py +448 -0
- scripts/collaboration/llm_cache_async.py +347 -0
- scripts/collaboration/llm_retry.py +387 -0
- scripts/collaboration/llm_retry_async.py +389 -0
- scripts/collaboration/mce_adapter.py +597 -0
- scripts/collaboration/memory_bridge.py +1607 -0
- scripts/collaboration/models.py +537 -0
- scripts/collaboration/null_providers.py +297 -0
- scripts/collaboration/operation_classifier.py +289 -0
- scripts/collaboration/output_slicer.py +225 -0
- scripts/collaboration/performance_monitor.py +462 -0
- scripts/collaboration/permission_guard.py +865 -0
- scripts/collaboration/prompt_assembler.py +756 -0
- scripts/collaboration/prompt_variant_generator.py +483 -0
- scripts/collaboration/protocols.py +267 -0
- scripts/collaboration/report_formatter.py +352 -0
- scripts/collaboration/retrospective.py +279 -0
- scripts/collaboration/role_matcher.py +92 -0
- scripts/collaboration/role_template_market.py +352 -0
- scripts/collaboration/rule_collector.py +678 -0
- scripts/collaboration/scratchpad.py +346 -0
- scripts/collaboration/skill_registry.py +151 -0
- scripts/collaboration/skillifier.py +878 -0
- scripts/collaboration/standardized_role_template.py +317 -0
- scripts/collaboration/task_completion_checker.py +237 -0
- scripts/collaboration/test_quality_guard.py +695 -0
- scripts/collaboration/unified_gate_engine.py +598 -0
- scripts/collaboration/usage_tracker.py +309 -0
- scripts/collaboration/user_friendly_error.py +176 -0
- scripts/collaboration/verification_gate.py +312 -0
- scripts/collaboration/warmup_manager.py +635 -0
- scripts/collaboration/worker.py +513 -0
- scripts/collaboration/workflow_engine.py +684 -0
- scripts/dashboard.py +1088 -0
- scripts/generate_benchmark_report.py +786 -0
- scripts/history_manager.py +604 -0
- scripts/mcp_server.py +289 -0
- skills/__init__.py +32 -0
- skills/dispatch/handler.py +52 -0
- skills/intent/handler.py +59 -0
- skills/registry.py +67 -0
- skills/retrospective/__init__.py +0 -0
- skills/retrospective/handler.py +125 -0
- skills/review/handler.py +356 -0
- skills/security/handler.py +454 -0
- skills/test/__init__.py +0 -0
- skills/test/handler.py +78 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Scratchpad - 共享黑板实现
|
|
5
|
+
|
|
6
|
+
设计决策(门禁1解决):
|
|
7
|
+
- 并发写入策略:时间戳排序 + 版本号 + 最后写入胜出(LWW)
|
|
8
|
+
- 对于发现类数据(FINDING/QUESTION),覆盖是可接受的
|
|
9
|
+
- 对于决策类数据(DECISION),需要 Consensus 机制保护
|
|
10
|
+
- 容量上限:默认 1000 条,LRU 淘汰最旧的非 RESOLVED 条目
|
|
11
|
+
- 存储选型(门禁3):内存主存储 + JSON 文件持久化备份
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import threading
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import Dict, List, Optional, Any
|
|
20
|
+
from collections import OrderedDict
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
from .models import (
|
|
25
|
+
ScratchpadEntry,
|
|
26
|
+
EntryType,
|
|
27
|
+
EntryStatus,
|
|
28
|
+
ReferenceType,
|
|
29
|
+
Reference,
|
|
30
|
+
)
|
|
31
|
+
from .usage_tracker import track_usage
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
MAX_ENTRIES_DEFAULT = 1000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Scratchpad:
|
|
38
|
+
"""
|
|
39
|
+
共享黑板 - 多 Worker 协作的信息交换中心
|
|
40
|
+
|
|
41
|
+
所有 Worker 通过 Scratchpad 共享发现、决策、问题和冲突。
|
|
42
|
+
采用 LWW (Last-Writer-Wins) 并发策略 + 版本号机制处理并发写入。
|
|
43
|
+
|
|
44
|
+
核心能力:
|
|
45
|
+
- write(): 写入条目(自动版本递增、容量管理、持久化)
|
|
46
|
+
- read(): 多维查询(关键词/类型/状态/Worker/标签/时间)
|
|
47
|
+
- resolve(): 标记冲突/问题为已解决
|
|
48
|
+
- get_summary(): 生成 Markdown 格式的全局摘要
|
|
49
|
+
|
|
50
|
+
设计特点:
|
|
51
|
+
- 容量上限: 默认 1000 条,LRU 淘汰最旧的非 RESOLVED 条目
|
|
52
|
+
- 持久化: JSONL 追加写模式,支持断点恢复
|
|
53
|
+
- 线程安全: RLock 保护所有读写操作
|
|
54
|
+
|
|
55
|
+
使用示例:
|
|
56
|
+
sp = Scratchpad(persist_dir="/tmp/sp_data")
|
|
57
|
+
entry = ScratchpadEntry(worker_id="w1", role_id="architect",
|
|
58
|
+
entry_type=EntryType.FINDING,
|
|
59
|
+
content="建议使用微服务架构")
|
|
60
|
+
sp.write(entry)
|
|
61
|
+
findings = sp.read(entry_type=EntryType.FINDING)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, scratchpad_id: Optional[str] = None, persist_dir: Optional[str] = None):
|
|
65
|
+
"""
|
|
66
|
+
初始化共享黑板
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
scratchpad_id: 黑板唯一标识(自动生成时间戳ID如未提供)
|
|
70
|
+
persist_dir: 持久化目录路径(为空则不持久化,纯内存模式)
|
|
71
|
+
"""
|
|
72
|
+
self.scratchpad_id = scratchpad_id or f"scratchpad-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
73
|
+
if '..' in self.scratchpad_id or '/' in self.scratchpad_id or '\\' in self.scratchpad_id:
|
|
74
|
+
raise ValueError(f"Invalid scratchpad_id (path traversal detected): {self.scratchpad_id}")
|
|
75
|
+
self.persist_dir = persist_dir
|
|
76
|
+
|
|
77
|
+
self._entries: OrderedDict[str, ScratchpadEntry] = OrderedDict()
|
|
78
|
+
self._lock = threading.RLock()
|
|
79
|
+
self._max_entries: int = MAX_ENTRIES_DEFAULT
|
|
80
|
+
self._write_count: int = 0
|
|
81
|
+
self._read_count: int = 0
|
|
82
|
+
|
|
83
|
+
if self.persist_dir:
|
|
84
|
+
os.makedirs(self.persist_dir, exist_ok=True)
|
|
85
|
+
self._load_from_disk()
|
|
86
|
+
|
|
87
|
+
def write(self, entry: ScratchpadEntry) -> str:
|
|
88
|
+
"""
|
|
89
|
+
[MCE 集成点 Phase C] Worker 写入共享黑板
|
|
90
|
+
|
|
91
|
+
当前行为: 直接存储原始 ScratchpadEntry (type/content/confidence)
|
|
92
|
+
MCE 就绪后: 每个 entry 写入前经过 MCE 分类标注
|
|
93
|
+
→ [decision] "我们决定用微服务架构"
|
|
94
|
+
→ [correction] "数据库从Mongo改为PostgreSQL"
|
|
95
|
+
→ [user_pref] "团队习惯TDD开发模式"
|
|
96
|
+
→ [relationship] "Alice负责后端API"
|
|
97
|
+
后续 Worker 读取时看到带类型的结构化发现,而非纯文本
|
|
98
|
+
|
|
99
|
+
接口预留: mce_engine 参数, enable_mce_annotate: bool
|
|
100
|
+
"""
|
|
101
|
+
with self._lock:
|
|
102
|
+
if len(self._entries) >= self._max_entries:
|
|
103
|
+
self._evict_oldest(count=len(self._entries) - self._max_entries + 1)
|
|
104
|
+
|
|
105
|
+
existing = self._entries.get(entry.entry_id)
|
|
106
|
+
if existing and existing.version >= entry.version:
|
|
107
|
+
entry.version = existing.version + 1
|
|
108
|
+
|
|
109
|
+
self._entries[entry.entry_id] = entry
|
|
110
|
+
self._write_count += 1
|
|
111
|
+
self._persist_entry(entry)
|
|
112
|
+
track_usage("scratchpad.write", success=True, metadata={
|
|
113
|
+
"entry_type": entry.entry_type.value
|
|
114
|
+
})
|
|
115
|
+
return entry.entry_id
|
|
116
|
+
|
|
117
|
+
def read(self, query: str = "", since: Optional[datetime] = None,
|
|
118
|
+
entry_type: Optional[EntryType] = None,
|
|
119
|
+
status: Optional[EntryStatus] = None,
|
|
120
|
+
worker_id: Optional[str] = None,
|
|
121
|
+
tags: Optional[List[str]] = None,
|
|
122
|
+
limit: int = 50) -> List[ScratchpadEntry]:
|
|
123
|
+
"""
|
|
124
|
+
多维查询黑板条目
|
|
125
|
+
|
|
126
|
+
支持按关键词、时间、类型、状态、Worker、标签等多维度组合过滤。
|
|
127
|
+
返回结果按时间倒序(最新的在前)。
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
query: 关键词模糊匹配(在 content 和 tags 中搜索)
|
|
131
|
+
since: 起始时间(只返回此之后的条目)
|
|
132
|
+
entry_type: 按条目类型过滤 (FINDING/QUESTION/DECISION/CONFLICT)
|
|
133
|
+
status: 按状态过滤 (ACTIVE/RESOLVED)
|
|
134
|
+
worker_id: 按 Worker ID 过滤
|
|
135
|
+
tags: 标签列表(任一匹配即返回)
|
|
136
|
+
limit: 最大返回条数
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
List[ScratchpadEntry]: 匹配的条目列表(时间倒序)
|
|
140
|
+
"""
|
|
141
|
+
with self._lock:
|
|
142
|
+
results = []
|
|
143
|
+
for entry in reversed(self._entries.values()):
|
|
144
|
+
if since and entry.timestamp < since:
|
|
145
|
+
continue
|
|
146
|
+
if entry_type and entry.entry_type != entry_type:
|
|
147
|
+
continue
|
|
148
|
+
if status and entry.status != status:
|
|
149
|
+
continue
|
|
150
|
+
if worker_id and entry.worker_id != worker_id:
|
|
151
|
+
continue
|
|
152
|
+
if tags and not any(t in entry.tags for t in tags):
|
|
153
|
+
continue
|
|
154
|
+
if query:
|
|
155
|
+
q_lower = query.lower()
|
|
156
|
+
if (q_lower not in entry.content.lower() and
|
|
157
|
+
not any(q_lower in t.lower() for t in entry.tags)):
|
|
158
|
+
continue
|
|
159
|
+
results.append(entry)
|
|
160
|
+
if len(results) >= limit:
|
|
161
|
+
break
|
|
162
|
+
self._read_count += 1
|
|
163
|
+
track_usage("scratchpad.read", success=True, metadata={
|
|
164
|
+
"results_count": len(results),
|
|
165
|
+
"has_query": bool(query)
|
|
166
|
+
})
|
|
167
|
+
return results
|
|
168
|
+
|
|
169
|
+
def resolve(self, entry_id: str, resolution: str = ""):
|
|
170
|
+
"""
|
|
171
|
+
将条目标记为已解决
|
|
172
|
+
|
|
173
|
+
修改条目状态为 RESOLVED,并可选地附加解决方案说明。
|
|
174
|
+
解决方案会追加到原内容末尾。
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
entry_id: 要解决的条目 ID
|
|
178
|
+
resolution: 解决方案描述(追加到原内容后)
|
|
179
|
+
"""
|
|
180
|
+
with self._lock:
|
|
181
|
+
entry = self._entries.get(entry_id)
|
|
182
|
+
if entry:
|
|
183
|
+
entry.status = EntryStatus.RESOLVED
|
|
184
|
+
if resolution:
|
|
185
|
+
entry.content = f"{entry.content}\n\n[RESOLVED] {resolution}"
|
|
186
|
+
entry.version += 1
|
|
187
|
+
self._persist_entry(entry)
|
|
188
|
+
|
|
189
|
+
def get_conflicts(self) -> List[ScratchpadEntry]:
|
|
190
|
+
"""
|
|
191
|
+
获取所有活跃冲突
|
|
192
|
+
|
|
193
|
+
快捷方法,等价于 read(entry_type=CONFLICT, status=ACTIVE)
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
List[ScratchpadEntry]: 当前未解决的冲突列表
|
|
197
|
+
"""
|
|
198
|
+
return self.read(
|
|
199
|
+
entry_type=EntryType.CONFLICT,
|
|
200
|
+
status=EntryStatus.ACTIVE,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def get_summary(self, for_role: Optional[str] = None,
|
|
204
|
+
max_entries: int = 20) -> str:
|
|
205
|
+
"""
|
|
206
|
+
生成黑板全局摘要(Markdown格式)
|
|
207
|
+
|
|
208
|
+
包含: 总览统计、活跃冲突列表、最近决策、关键发现。
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
for_role: 为指定角色定制摘要(预留参数)
|
|
212
|
+
max_entries: 各类别的最大展示条数
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
str: Markdown 格式的摘要文本
|
|
216
|
+
"""
|
|
217
|
+
active_findings = self.read(
|
|
218
|
+
entry_type=EntryType.FINDING,
|
|
219
|
+
status=EntryStatus.ACTIVE,
|
|
220
|
+
limit=max_entries,
|
|
221
|
+
)
|
|
222
|
+
decisions = self.read(
|
|
223
|
+
entry_type=EntryType.DECISION,
|
|
224
|
+
status=EntryStatus.ACTIVE,
|
|
225
|
+
limit=max_entries // 2,
|
|
226
|
+
)
|
|
227
|
+
conflicts = self.get_conflicts()
|
|
228
|
+
|
|
229
|
+
lines = [f"# Scratchpad Summary ({self.scratchpad_id})"]
|
|
230
|
+
lines.append(f"**Total entries**: {len(self._entries)} | **Active findings**: {len(active_findings)} | **Conflicts**: {len(conflicts)}")
|
|
231
|
+
lines.append("")
|
|
232
|
+
|
|
233
|
+
if conflicts:
|
|
234
|
+
lines.append(f"## ⚠️ Active Conflicts ({len(conflicts)})")
|
|
235
|
+
for c in conflicts[:5]:
|
|
236
|
+
role_tag = f"[{c.role_id}]"
|
|
237
|
+
conf_str = f"- {role_tag} {c.content[:100]}"
|
|
238
|
+
lines.append(conf_str)
|
|
239
|
+
lines.append("")
|
|
240
|
+
|
|
241
|
+
if decisions:
|
|
242
|
+
lines.append(f"## ✅ Recent Decisions ({len(decisions)})")
|
|
243
|
+
for d in decisions[:10]:
|
|
244
|
+
role_tag = f"[{d.role_id}]"
|
|
245
|
+
dec_str = f"- {role_tag} {d.content[:120]} (confidence: {d.confidence:.0%})"
|
|
246
|
+
lines.append(dec_str)
|
|
247
|
+
lines.append("")
|
|
248
|
+
|
|
249
|
+
if active_findings:
|
|
250
|
+
lines.append(f"## 🔍 Key Findings ({len(active_findings)})")
|
|
251
|
+
for f in active_findings[:15]:
|
|
252
|
+
role_tag = f"[{f.worker_id}/{f.role_id}]"
|
|
253
|
+
find_str = f"- {role_tag} {f.content[:120]} (confidence: {f.confidence:.0%})"
|
|
254
|
+
lines.append(find_str)
|
|
255
|
+
|
|
256
|
+
return "\n".join(lines)
|
|
257
|
+
|
|
258
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
259
|
+
"""
|
|
260
|
+
获取黑板详细统计信息
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Dict[str, Any]: 统计字典,包含:
|
|
264
|
+
- scratchpad_id: 黑板标识
|
|
265
|
+
- total_entries: 总条目数
|
|
266
|
+
- by_type: 按类型分布
|
|
267
|
+
- by_status: 按状态分布
|
|
268
|
+
- by_worker: 按 Worker 分布
|
|
269
|
+
- write_count/read_count: 读写计数
|
|
270
|
+
- max_entries: 容量上限
|
|
271
|
+
"""
|
|
272
|
+
with self._lock:
|
|
273
|
+
by_type = {}
|
|
274
|
+
by_status = {}
|
|
275
|
+
by_worker = {}
|
|
276
|
+
for e in self._entries.values():
|
|
277
|
+
by_type[e.entry_type.value] = by_type.get(e.entry_type.value, 0) + 1
|
|
278
|
+
by_status[e.status.value] = by_status.get(e.status.value, 0) + 1
|
|
279
|
+
by_worker[e.worker_id] = by_worker.get(e.worker_id, 0) + 1
|
|
280
|
+
return {
|
|
281
|
+
"scratchpad_id": self.scratchpad_id,
|
|
282
|
+
"total_entries": len(self._entries),
|
|
283
|
+
"by_type": by_type,
|
|
284
|
+
"by_status": by_status,
|
|
285
|
+
"by_worker": by_worker,
|
|
286
|
+
"write_count": self._write_count,
|
|
287
|
+
"read_count": self._read_count,
|
|
288
|
+
"max_entries": self._max_entries,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
def _evict_oldest(self, count: int = 1):
|
|
292
|
+
to_evict = []
|
|
293
|
+
for eid, entry in self._entries.items():
|
|
294
|
+
if entry.status == EntryStatus.RESOLVED:
|
|
295
|
+
to_evict.append((eid, entry.timestamp))
|
|
296
|
+
if len(to_evict) >= count:
|
|
297
|
+
break
|
|
298
|
+
if not to_evict:
|
|
299
|
+
to_evict = [(eid, e.timestamp) for eid, e in list(self._entries.items())[:count]]
|
|
300
|
+
to_evict.sort(key=lambda x: x[1])
|
|
301
|
+
for eid, _ in to_evict:
|
|
302
|
+
del self._entries[eid]
|
|
303
|
+
|
|
304
|
+
def _persist_entry(self, entry: ScratchpadEntry):
|
|
305
|
+
if not self.persist_dir:
|
|
306
|
+
return
|
|
307
|
+
filepath = os.path.join(self.persist_dir, f"{self.scratchpad_id}.jsonl")
|
|
308
|
+
try:
|
|
309
|
+
with open(filepath, "a", encoding="utf-8") as f:
|
|
310
|
+
f.write(json.dumps(entry.to_dict(), ensure_ascii=False) + "\n")
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.warning("Failed to persist scratchpad entry %s: %s", entry.entry_id, e)
|
|
313
|
+
|
|
314
|
+
def _load_from_disk(self):
|
|
315
|
+
if not self.persist_dir:
|
|
316
|
+
return
|
|
317
|
+
filepath = os.path.join(self.persist_dir, f"{self.scratchpad_id}.jsonl")
|
|
318
|
+
if not os.path.exists(filepath):
|
|
319
|
+
return
|
|
320
|
+
try:
|
|
321
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
322
|
+
for line in f:
|
|
323
|
+
line = line.strip()
|
|
324
|
+
if not line:
|
|
325
|
+
continue
|
|
326
|
+
data = json.loads(line)
|
|
327
|
+
entry = ScratchpadEntry.from_dict(data)
|
|
328
|
+
self._entries[entry.entry_id] = entry
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.warning("Failed to load scratchpad from %s: %s", filepath, e)
|
|
331
|
+
|
|
332
|
+
def clear(self):
|
|
333
|
+
"""清空所有黑板条目(仅内存,不影响已持久化的文件)"""
|
|
334
|
+
with self._lock:
|
|
335
|
+
self._entries.clear()
|
|
336
|
+
|
|
337
|
+
def export_json(self) -> str:
|
|
338
|
+
"""
|
|
339
|
+
导出所有条目为 JSON 字符串
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
str: JSON 格式的完整数据快照
|
|
343
|
+
"""
|
|
344
|
+
with self._lock:
|
|
345
|
+
entries = [e.to_dict() for e in self._entries.values()]
|
|
346
|
+
return json.dumps(entries, ensure_ascii=False, indent=2)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import hashlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Any, Optional, Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SkillEntry:
|
|
15
|
+
skill_id: str = field(default_factory=lambda: f"skill-{hashlib.md5(str(datetime.now().isoformat()).encode()).hexdigest()[:8]}")
|
|
16
|
+
name: str = ""
|
|
17
|
+
description: str = ""
|
|
18
|
+
category: str = "general"
|
|
19
|
+
version: str = "1.0.0"
|
|
20
|
+
handler: Optional[str] = None
|
|
21
|
+
tags: List[str] = field(default_factory=list)
|
|
22
|
+
confidence: float = 0.0
|
|
23
|
+
usage_count: int = 0
|
|
24
|
+
last_used: Optional[str] = None
|
|
25
|
+
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
26
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
29
|
+
return {
|
|
30
|
+
'skill_id': self.skill_id, 'name': self.name, 'description': self.description,
|
|
31
|
+
'category': self.category, 'version': self.version, 'handler': self.handler,
|
|
32
|
+
'tags': self.tags, 'confidence': self.confidence, 'usage_count': self.usage_count,
|
|
33
|
+
'last_used': self.last_used, 'created_at': self.created_at, 'metadata': self.metadata,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'SkillEntry':
|
|
38
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SkillRegistry:
|
|
42
|
+
"""
|
|
43
|
+
Skill registry for DevSquad.
|
|
44
|
+
|
|
45
|
+
Manages reusable skill definitions that can be:
|
|
46
|
+
- Auto-discovered from dispatch results
|
|
47
|
+
- Manually registered by users
|
|
48
|
+
- Matched to new tasks by category/tags
|
|
49
|
+
- Persisted to disk for cross-session reuse
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, storage_path: str = "./skills"):
|
|
53
|
+
self.storage_path = Path(storage_path)
|
|
54
|
+
self.storage_path.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
self.skills: Dict[str, SkillEntry] = {}
|
|
56
|
+
self.handlers: Dict[str, Callable] = {}
|
|
57
|
+
self._load()
|
|
58
|
+
|
|
59
|
+
def register(self, skill: SkillEntry, handler: Optional[Callable] = None) -> str:
|
|
60
|
+
if '..' in skill.skill_id or '/' in skill.skill_id or '\\' in skill.skill_id:
|
|
61
|
+
raise ValueError(f"Invalid skill_id: {skill.skill_id}")
|
|
62
|
+
self.skills[skill.skill_id] = skill
|
|
63
|
+
if handler:
|
|
64
|
+
self.handlers[skill.skill_id] = handler
|
|
65
|
+
self._save()
|
|
66
|
+
logger.info("Skill registered: %s (%s)", skill.name, skill.skill_id)
|
|
67
|
+
return skill.skill_id
|
|
68
|
+
|
|
69
|
+
def unregister(self, skill_id: str) -> bool:
|
|
70
|
+
if skill_id in self.skills:
|
|
71
|
+
del self.skills[skill_id]
|
|
72
|
+
self.handlers.pop(skill_id, None)
|
|
73
|
+
self._save()
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def get(self, skill_id: str) -> Optional[SkillEntry]:
|
|
78
|
+
return self.skills.get(skill_id)
|
|
79
|
+
|
|
80
|
+
def execute(self, skill_id: str, **kwargs) -> Any:
|
|
81
|
+
skill = self.skills.get(skill_id)
|
|
82
|
+
if not skill:
|
|
83
|
+
raise ValueError(f"Skill not found: {skill_id}")
|
|
84
|
+
|
|
85
|
+
handler = self.handlers.get(skill_id)
|
|
86
|
+
if not handler:
|
|
87
|
+
raise ValueError(f"No handler for skill: {skill_id}")
|
|
88
|
+
|
|
89
|
+
skill.usage_count += 1
|
|
90
|
+
skill.last_used = datetime.now().isoformat()
|
|
91
|
+
self._save()
|
|
92
|
+
|
|
93
|
+
return handler(**kwargs)
|
|
94
|
+
|
|
95
|
+
def search(self, query: str = "", category: str = "", tags: List[str] = None) -> List[SkillEntry]:
|
|
96
|
+
results = list(self.skills.values())
|
|
97
|
+
if category:
|
|
98
|
+
results = [s for s in results if s.category == category]
|
|
99
|
+
if tags:
|
|
100
|
+
results = [s for s in results if any(t in s.tags for t in tags)]
|
|
101
|
+
if query:
|
|
102
|
+
q = query.lower()
|
|
103
|
+
results = [s for s in results if q in s.name.lower() or q in s.description.lower()]
|
|
104
|
+
results.sort(key=lambda s: s.confidence, reverse=True)
|
|
105
|
+
return results
|
|
106
|
+
|
|
107
|
+
def propose_from_result(self, name: str, description: str, category: str = "",
|
|
108
|
+
confidence: float = 0.0, tags: List[str] = None) -> SkillEntry:
|
|
109
|
+
skill = SkillEntry(
|
|
110
|
+
name=name, description=description, category=category,
|
|
111
|
+
confidence=confidence, tags=tags or [],
|
|
112
|
+
)
|
|
113
|
+
self.register(skill)
|
|
114
|
+
return skill
|
|
115
|
+
|
|
116
|
+
def list_skills(self, category: str = "") -> List[Dict[str, Any]]:
|
|
117
|
+
skills = list(self.skills.values())
|
|
118
|
+
if category:
|
|
119
|
+
skills = [s for s in skills if s.category == category]
|
|
120
|
+
return [s.to_dict() for s in skills]
|
|
121
|
+
|
|
122
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
123
|
+
categories = {}
|
|
124
|
+
for s in self.skills.values():
|
|
125
|
+
categories[s.category] = categories.get(s.category, 0) + 1
|
|
126
|
+
return {
|
|
127
|
+
"total_skills": len(self.skills),
|
|
128
|
+
"categories": categories,
|
|
129
|
+
"with_handlers": len(self.handlers),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def _load(self):
|
|
133
|
+
registry_file = self.storage_path / "registry.json"
|
|
134
|
+
if registry_file.exists():
|
|
135
|
+
try:
|
|
136
|
+
with open(registry_file, 'r', encoding='utf-8') as f:
|
|
137
|
+
data = json.load(f)
|
|
138
|
+
for skill_data in data.get('skills', []):
|
|
139
|
+
skill = SkillEntry.from_dict(skill_data)
|
|
140
|
+
self.skills[skill.skill_id] = skill
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.warning("Failed to load skill registry: %s", e)
|
|
143
|
+
|
|
144
|
+
def _save(self):
|
|
145
|
+
registry_file = self.storage_path / "registry.json"
|
|
146
|
+
try:
|
|
147
|
+
data = {'skills': [s.to_dict() for s in self.skills.values()]}
|
|
148
|
+
with open(registry_file, 'w', encoding='utf-8') as f:
|
|
149
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.warning("Failed to save skill registry: %s", e)
|