@hupan56/wlkj 3.3.0 → 3.3.1
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/package.json +2 -2
- package/templates/.qoder/.runtime/hook-errors.log +4 -0
- package/templates/qoder/commands/optional/wl-status.md +2 -0
- package/templates/qoder/commands/wl-code.md +45 -10
- package/templates/qoder/commands/wl-init.md +129 -129
- package/templates/qoder/commands/wl-prd.md +27 -12
- package/templates/qoder/commands/wl-search.md +32 -0
- package/templates/qoder/commands/wl-task.md +8 -4
- package/templates/qoder/commands/wl-test.md +4 -0
- package/templates/qoder/hooks/post-tool-use.py +31 -1
- package/templates/qoder/hooks/pre-tool-use.py +136 -0
- package/templates/qoder/hooks/session-start.py +365 -365
- package/templates/qoder/scripts/capability/__pycache__/present_html.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/__pycache__/registry.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__init__.py +4 -4
- package/templates/qoder/scripts/capability/caps/__pycache__/__init__.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__pycache__/notify.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/__pycache__/sandbox.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/capability/caps/notify.py +64 -0
- package/templates/qoder/scripts/capability/caps/sandbox.py +38 -0
- package/templates/qoder/scripts/capability/present_html.py +68 -0
- package/templates/qoder/scripts/capability/registry.py +6 -2
- package/templates/qoder/scripts/capability/registry_mcp.py +4 -6
- package/templates/qoder/scripts/domain/integration/__pycache__/return_to_platform.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/domain/integration/return_to_platform.py +9 -6
- package/templates/qoder/scripts/domain/integration/spec_upload.py +1 -2
- package/templates/qoder/scripts/domain/kg/switch_project.py +5 -6
- package/templates/qoder/scripts/engine/poller.py +1 -1
- package/templates/qoder/scripts/validation/metrics/__pycache__/present_board.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/validation/metrics/present_board.py +180 -0
- package/templates/qoder/settings.json +10 -0
- package/templates/qoder/scripts/domain/kg/extract/extract.py.bak +0 -430
|
Binary file
|
|
Binary file
|
|
@@ -11,9 +11,9 @@ from .repo import RepoCap, NoOpRepoCap
|
|
|
11
11
|
from .identity import IdentityCap, NoOpIdentityCap
|
|
12
12
|
from .context import ContextCap, NoOpContextCap
|
|
13
13
|
from .cron import CronCap, NoOpCronCap
|
|
14
|
-
from .notify import NotifyCap, NoOpNotifyCap
|
|
14
|
+
from .notify import NotifyCap, NoOpNotifyCap, WebhookNotifyCap, default_notify
|
|
15
15
|
from .present import PresentCap, NoOpPresentCap
|
|
16
|
-
from .sandbox import SandboxCap, NoOpSandboxCap
|
|
16
|
+
from .sandbox import SandboxCap, NoOpSandboxCap, SubprocessSandboxCap, default_sandbox
|
|
17
17
|
|
|
18
18
|
__all__ = [
|
|
19
19
|
"MemoryCap", "NoOpMemoryCap",
|
|
@@ -21,7 +21,7 @@ __all__ = [
|
|
|
21
21
|
"IdentityCap", "NoOpIdentityCap",
|
|
22
22
|
"ContextCap", "NoOpContextCap",
|
|
23
23
|
"CronCap", "NoOpCronCap",
|
|
24
|
-
"NotifyCap", "NoOpNotifyCap",
|
|
24
|
+
"NotifyCap", "NoOpNotifyCap", "WebhookNotifyCap", "default_notify",
|
|
25
25
|
"PresentCap", "NoOpPresentCap",
|
|
26
|
-
"SandboxCap", "NoOpSandboxCap",
|
|
26
|
+
"SandboxCap", "NoOpSandboxCap", "SubprocessSandboxCap", "default_sandbox",
|
|
27
27
|
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -11,6 +11,9 @@ NotifyCap —— IM 通知能力契约。
|
|
|
11
11
|
"""
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import urllib.request
|
|
14
17
|
from typing import Protocol, runtime_checkable
|
|
15
18
|
|
|
16
19
|
|
|
@@ -39,3 +42,64 @@ class NoOpNotifyCap:
|
|
|
39
42
|
|
|
40
43
|
def list_channels(self) -> list[dict]:
|
|
41
44
|
return []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class WebhookNotifyCap:
|
|
48
|
+
"""真实通知 adapter(#17):POST 消息到配置的 IM webhook(飞书/企业微信/钉钉)。
|
|
49
|
+
|
|
50
|
+
webhook 从 env WLKJ_NOTIFY_WEBHOOKS 解析:
|
|
51
|
+
"default=https://open.feishu.cn/...,review=https://..." 或 JSON '{"default":"..."}'
|
|
52
|
+
用 stdlib urllib(宿主脚本可能无 httpx/requests),POST 通用 text payload。
|
|
53
|
+
available 仅当至少配了一个 webhook,否则 False(优雅降级 NoOp,不谎报)。
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self._channels: dict[str, str] = self._load()
|
|
58
|
+
|
|
59
|
+
def _load(self) -> dict[str, str]:
|
|
60
|
+
raw = os.environ.get("WLKJ_NOTIFY_WEBHOOKS", "").strip()
|
|
61
|
+
if not raw:
|
|
62
|
+
return {}
|
|
63
|
+
if raw.startswith("{"): # JSON 形式
|
|
64
|
+
try:
|
|
65
|
+
d = json.loads(raw)
|
|
66
|
+
return {str(k): str(v) for k, v in d.items() if v}
|
|
67
|
+
except Exception:
|
|
68
|
+
return {}
|
|
69
|
+
out: dict[str, str] = {}
|
|
70
|
+
for pair in raw.split(","):
|
|
71
|
+
if "=" in pair:
|
|
72
|
+
k, v = pair.split("=", 1)
|
|
73
|
+
k, v = k.strip(), v.strip()
|
|
74
|
+
if v:
|
|
75
|
+
out[k] = v
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def available(self) -> bool:
|
|
80
|
+
return len(self._channels) > 0
|
|
81
|
+
|
|
82
|
+
def send(self, channel: str, message: str) -> bool:
|
|
83
|
+
url = self._channels.get(channel) or self._channels.get("default")
|
|
84
|
+
if not url:
|
|
85
|
+
return False
|
|
86
|
+
try:
|
|
87
|
+
# 通用 text payload(飞书 msg_type/content;钉钉/企微也兼容 text 字段)
|
|
88
|
+
payload = json.dumps({"msg_type": "text", "content": {"text": message}}).encode("utf-8")
|
|
89
|
+
req = urllib.request.Request(url, data=payload,
|
|
90
|
+
headers={"Content-Type": "application/json"})
|
|
91
|
+
with urllib.request.urlopen(req, timeout=5) as r:
|
|
92
|
+
return getattr(r, "status", 200) == 200
|
|
93
|
+
except Exception:
|
|
94
|
+
return False # IM 不可达不阻塞工作流
|
|
95
|
+
|
|
96
|
+
def list_channels(self) -> list[dict]:
|
|
97
|
+
return [{"channel": k, "configured": bool(v)} for k, v in self._channels.items()]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def default_notify() -> NotifyCap:
|
|
101
|
+
"""工厂:配了 webhook→WebhookNotifyCap;否则 NoOpNotifyCap。"""
|
|
102
|
+
cap = WebhookNotifyCap()
|
|
103
|
+
if cap.available:
|
|
104
|
+
return cap # type: ignore[return-value]
|
|
105
|
+
return NoOpNotifyCap() # type: ignore[return-value]
|
|
@@ -13,6 +13,8 @@ SandboxCap —— 安全执行能力契约。
|
|
|
13
13
|
"""
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
16
18
|
from typing import Protocol, runtime_checkable
|
|
17
19
|
|
|
18
20
|
|
|
@@ -41,3 +43,39 @@ class NoOpSandboxCap:
|
|
|
41
43
|
|
|
42
44
|
def approval_mode(self) -> str:
|
|
43
45
|
return "suggest"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SubprocessSandboxCap:
|
|
49
|
+
"""真实沙箱 adapter(#17):子进程执行 + 超时强杀 + stdout/stderr/returncode 捕获。
|
|
50
|
+
|
|
51
|
+
无 docker 时的进程级隔离(超时强杀 + 输出捕获 + cwd 隔离),全平台可用。
|
|
52
|
+
docker 容器级隔离(FS/网络限制)后续可加 DockerSandboxCap,工厂按 env 切换。
|
|
53
|
+
available=True(subprocess 全平台可用)。
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
available = True
|
|
57
|
+
|
|
58
|
+
def run(self, command: str, cwd: str | None = None, timeout: int = 60) -> dict:
|
|
59
|
+
try:
|
|
60
|
+
# errors='replace' 健壮化:Windows 命令常输出 GBK 中文,UTF-8 解码会崩;
|
|
61
|
+
# replace 不崩(个别字符显示?),比 exec_error 丢全部输出好。
|
|
62
|
+
p = subprocess.run(command, shell=True, cwd=cwd, timeout=timeout,
|
|
63
|
+
capture_output=True, text=True, errors="replace")
|
|
64
|
+
return {"ok": p.returncode == 0, "stdout": p.stdout,
|
|
65
|
+
"stderr": p.stderr, "returncode": p.returncode}
|
|
66
|
+
except subprocess.TimeoutExpired:
|
|
67
|
+
return {"ok": False, "reason": "timeout(%ds)" % timeout, "returncode": -1}
|
|
68
|
+
except Exception as e: # 命令不存在 / 权限 等
|
|
69
|
+
return {"ok": False, "reason": "exec_error: %s" % str(e)[:200], "returncode": -1}
|
|
70
|
+
|
|
71
|
+
def approval_mode(self) -> str:
|
|
72
|
+
return os.environ.get("WLKJ_APPROVAL_MODE", "suggest")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def default_sandbox() -> SandboxCap:
|
|
76
|
+
"""工厂:env WLKJ_SANDBOX=off→NoOp;docker(占位)→Subprocess 兜底;默认 Subprocess。"""
|
|
77
|
+
mode = os.environ.get("WLKJ_SANDBOX", "subprocess").lower()
|
|
78
|
+
if mode == "off":
|
|
79
|
+
return NoOpSandboxCap() # type: ignore[return-value]
|
|
80
|
+
# docker 模式暂未实现容器隔离,先兜底 Subprocess(避免谎报容器级安全)
|
|
81
|
+
return SubprocessSandboxCap() # type: ignore[return-value]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""present_html.py — 富展示 HTML 统一入口(cap.present 双轨)。
|
|
3
|
+
|
|
4
|
+
模式(抄 usability_score.py --html):
|
|
5
|
+
1. QoderWork(cap.present.available)→ cap.present.widget(html) 嵌入对话流
|
|
6
|
+
2. 降级 → 落盘 workspace/members/{dev}/journal/{name}-{ts}.html
|
|
7
|
+
3. 最终兜底 → stdout
|
|
8
|
+
|
|
9
|
+
所有命令的富展示(impact/coverage/feature/report 看板)统一调 present(html, name),
|
|
10
|
+
不在各自脚本重复 widget+落盘逻辑。复用点:caps/present.py PresentCap + adapters/qw.py QwPresentCap。
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_repo(repo_root=None):
|
|
18
|
+
if repo_root:
|
|
19
|
+
return Path(repo_root)
|
|
20
|
+
env = os.environ.get("QODER_REPO") or os.environ.get("WLKJ_REPO")
|
|
21
|
+
if env:
|
|
22
|
+
return Path(env)
|
|
23
|
+
p = Path(__file__).resolve()
|
|
24
|
+
for parent in p.parents:
|
|
25
|
+
if (parent / ".qoder").is_dir() or (parent / "packages").is_dir():
|
|
26
|
+
return parent
|
|
27
|
+
return p.parent
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_dev(repo):
|
|
31
|
+
f = repo / ".qoder" / ".developer"
|
|
32
|
+
if f.is_file():
|
|
33
|
+
for line in f.read_text(encoding="utf-8").splitlines():
|
|
34
|
+
if "name=" in line:
|
|
35
|
+
return line.split("name=", 1)[1].strip().split()[0]
|
|
36
|
+
return os.environ.get("QODER_DEV", "") or "shared"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def present(html, name="dashboard", repo_root=None, dev=None):
|
|
40
|
+
"""展示 HTML:QoderWork 嵌入对话;降级落盘 journal;兜底 stdout。返回 (sinked, path)。"""
|
|
41
|
+
repo = _resolve_repo(repo_root)
|
|
42
|
+
# 1. QoderWork widget(富宿主嵌入对话流)
|
|
43
|
+
try:
|
|
44
|
+
scripts_dir = str(repo / ".qoder" / "scripts")
|
|
45
|
+
if scripts_dir not in sys.path:
|
|
46
|
+
sys.path.insert(0, scripts_dir)
|
|
47
|
+
from capability.registry import resolve
|
|
48
|
+
cap = resolve()
|
|
49
|
+
if getattr(cap.present, "available", False):
|
|
50
|
+
if cap.present.widget(html):
|
|
51
|
+
return True, None
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
# 2. 降级落盘
|
|
55
|
+
try:
|
|
56
|
+
out_dev = dev or _read_dev(repo)
|
|
57
|
+
out = repo / "workspace" / "members" / out_dev / "journal"
|
|
58
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
from datetime import datetime
|
|
60
|
+
import re
|
|
61
|
+
safe_name = re.sub(r'[\\/:*?"<>|]', "_", name) # Windows 非法字符清洗
|
|
62
|
+
fp = out / ("%s-%s.html" % (safe_name, datetime.now().strftime("%Y%m%d-%H%M%S")))
|
|
63
|
+
fp.write_text(html, encoding="utf-8")
|
|
64
|
+
print("[HTML] 已落盘: %s" % fp)
|
|
65
|
+
return False, str(fp)
|
|
66
|
+
except Exception:
|
|
67
|
+
print(html)
|
|
68
|
+
return False, None
|
|
@@ -196,7 +196,9 @@ def _resolve_cron(host: str):
|
|
|
196
196
|
|
|
197
197
|
|
|
198
198
|
def _resolve_notify(host: str):
|
|
199
|
-
|
|
199
|
+
"""L2 通知 —— 配了 WLKJ_NOTIFY_WEBHOOKS→WebhookNotifyCap(飞书/企微/钉钉);否则 NoOp 降级。"""
|
|
200
|
+
from .caps.notify import default_notify
|
|
201
|
+
return default_notify()
|
|
200
202
|
|
|
201
203
|
|
|
202
204
|
def _resolve_present(host: str):
|
|
@@ -211,7 +213,9 @@ def _resolve_present(host: str):
|
|
|
211
213
|
|
|
212
214
|
|
|
213
215
|
def _resolve_sandbox(host: str):
|
|
214
|
-
|
|
216
|
+
"""L2 安全执行 —— env WLKJ_SANDBOX≠off→SubprocessSandboxCap(进程级隔离+超时);off→NoOp。"""
|
|
217
|
+
from .caps.sandbox import default_sandbox
|
|
218
|
+
return default_sandbox()
|
|
215
219
|
|
|
216
220
|
|
|
217
221
|
def _resolve_mcp(host: str):
|
|
@@ -39,7 +39,7 @@ from protocol.transports import get_transport, Transport, NoOpTransport, ToolRes
|
|
|
39
39
|
# -----------------------------------------------------------------------------
|
|
40
40
|
# 历史 bug: mcp_config.json / registry_mcp_config.json 是"死配置"(全仓 0 读取),
|
|
41
41
|
# registry 用硬编码 SERVERS。改配置文件不生效。
|
|
42
|
-
# 现在: 优先读
|
|
42
|
+
# 现在: 优先读 .qoder/mcp_config.json 的 mcpServers,读不到才 fallback。
|
|
43
43
|
# =============================================================================
|
|
44
44
|
# 配置文件中知识层 server 的别名映射(统一命名,两边都支持):
|
|
45
45
|
# mcp_config.json 里叫 "wlinkj-knowledge",本地硬编码叫 "qoder-knowledge-graph"。
|
|
@@ -51,7 +51,7 @@ _KNOWLEDGE_SERVER_ALIASES = ["wlinkj-knowledge", "qoder-knowledge-graph"]
|
|
|
51
51
|
SERVERS: dict[str, dict] = {
|
|
52
52
|
"qoder-knowledge-graph": {
|
|
53
53
|
"kind": "http",
|
|
54
|
-
"url": "http://
|
|
54
|
+
"url": os.environ.get("WLKJ_BASE_URL", "http://10.89.7.120") + "/mcp",
|
|
55
55
|
"note": "知识图谱。已改为 http 模式,指向资产平台 MCP 服务(POST /mcp JSON-RPC)",
|
|
56
56
|
},
|
|
57
57
|
"qoder-zentao": {
|
|
@@ -73,13 +73,11 @@ SERVERS: dict[str, dict] = {
|
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
def _mcp_config_path() -> Path:
|
|
76
|
-
"""定位
|
|
76
|
+
"""定位 mcp_config.json(在仓库根/宿主下)。"""
|
|
77
77
|
repo = _get_repo_root()
|
|
78
|
-
# registry_mcp.py 在 .../scripts/capability/, mcp_config.json
|
|
79
|
-
# 仓库根可能就是 wlinkj-workflow/, 或是其上层。两级查找。
|
|
78
|
+
# registry_mcp.py 在 .../scripts/capability/, mcp_config.json 在宿主根(.qoder)。
|
|
80
79
|
for cand in [
|
|
81
80
|
repo / "mcp_config.json",
|
|
82
|
-
repo / "wlinkj-workflow" / "mcp_config.json",
|
|
83
81
|
repo / "registry_mcp_config.json",
|
|
84
82
|
]:
|
|
85
83
|
if cand.is_file():
|
package/templates/qoder/scripts/domain/integration/__pycache__/return_to_platform.cpython-39.pyc
CHANGED
|
Binary file
|
|
@@ -7,7 +7,7 @@ integration.return_to_platform —— 引擎产出回流平台的统一通道。
|
|
|
7
7
|
历史断点(已修):
|
|
8
8
|
引擎产出 PRD/原型只写本地 workspace/members/{dev}/drafts/,永不回平台,
|
|
9
9
|
平台 AI回流 Tab 永远空。原因: 全仓 grep submit_return/create_prd/create_prototype
|
|
10
|
-
在
|
|
10
|
+
在 .qoder 下 0 匹配 —— 引擎根本不知道有回流这回事。
|
|
11
11
|
|
|
12
12
|
本模块提供两条回流路径,任一可用即回流成功:
|
|
13
13
|
1. MCP 写工具(首选): cap.mcp.call("submit_return"|"create_prd"|"create_prototype")
|
|
@@ -20,11 +20,12 @@ integration.return_to_platform —— 引擎产出回流平台的统一通道。
|
|
|
20
20
|
"""
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
+
import os
|
|
23
24
|
from typing import Optional
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def _load_mcp_config() -> dict:
|
|
27
|
-
"""读
|
|
28
|
+
"""读 .qoder/mcp_config.json,失败返回空 dict。"""
|
|
28
29
|
import json as _json
|
|
29
30
|
from pathlib import Path
|
|
30
31
|
|
|
@@ -35,7 +36,6 @@ def _load_mcp_config() -> dict:
|
|
|
35
36
|
repo = Path(__file__).resolve().parent.parent.parent.parent.parent
|
|
36
37
|
for cand in [
|
|
37
38
|
repo / "mcp_config.json",
|
|
38
|
-
repo / "wlinkj-workflow" / "mcp_config.json",
|
|
39
39
|
]:
|
|
40
40
|
try:
|
|
41
41
|
if cand.is_file():
|
|
@@ -122,7 +122,7 @@ def _via_http(payload: dict) -> tuple[bool, str]:
|
|
|
122
122
|
|
|
123
123
|
|
|
124
124
|
def _platform_base() -> str:
|
|
125
|
-
"""平台 base url
|
|
125
|
+
"""平台 base url(默认 http://10.89.7.120 云),从 mcp_config server url 推导。"""
|
|
126
126
|
from urllib.parse import urlparse
|
|
127
127
|
cfg = _load_mcp_config()
|
|
128
128
|
for srv in (cfg.get("mcpServers") or {}).values():
|
|
@@ -134,7 +134,7 @@ def _platform_base() -> str:
|
|
|
134
134
|
if ep:
|
|
135
135
|
p = urlparse(ep)
|
|
136
136
|
return "%s://%s" % (p.scheme, p.netloc)
|
|
137
|
-
return "http://
|
|
137
|
+
return os.environ.get("WLKJ_BASE_URL", "http://10.89.7.120")
|
|
138
138
|
|
|
139
139
|
|
|
140
140
|
def _engine_token() -> Optional[str]:
|
|
@@ -175,7 +175,8 @@ def _post_to_platform(path: str, payload: dict, timeout: int = 15) -> tuple[bool
|
|
|
175
175
|
def submit_prd(prd_path: str, title: str, source: str = "wlkj/wl-prd",
|
|
176
176
|
platform: Optional[str] = None,
|
|
177
177
|
version_id: Optional[str] = None,
|
|
178
|
-
requirement_id: Optional[str] = None
|
|
178
|
+
requirement_id: Optional[str] = None,
|
|
179
|
+
confidence: Optional[float] = None) -> dict:
|
|
179
180
|
"""回流一份 PRD 到平台。
|
|
180
181
|
|
|
181
182
|
优先 create_prd(写 PRD 表),失败再 submit_return(写 returns 待审核),
|
|
@@ -202,6 +203,8 @@ def submit_prd(prd_path: str, title: str, source: str = "wlkj/wl-prd",
|
|
|
202
203
|
"content_full": content_md,
|
|
203
204
|
"target_location": "PRD草稿",
|
|
204
205
|
}
|
|
206
|
+
if confidence is not None:
|
|
207
|
+
return_args["confidence"] = confidence # #20 回流质量分级
|
|
205
208
|
if version_id:
|
|
206
209
|
return_args["relations"] = "version_id=%s" % version_id
|
|
207
210
|
if requirement_id:
|
|
@@ -25,7 +25,6 @@ def _load_mcp_config() -> dict:
|
|
|
25
25
|
except Exception:
|
|
26
26
|
repo = Path(__file__).resolve().parent.parent.parent.parent.parent
|
|
27
27
|
for cand in [repo / "mcp_config.json",
|
|
28
|
-
repo / "wlinkj-workflow" / "mcp_config.json",
|
|
29
28
|
repo / ".qoder" / "mcp_config.json"]:
|
|
30
29
|
try:
|
|
31
30
|
if cand.is_file():
|
|
@@ -49,7 +48,7 @@ def _resolve_endpoint() -> Tuple[str, str, str]:
|
|
|
49
48
|
u = srv.get("url", "")
|
|
50
49
|
if "/mcp" in u:
|
|
51
50
|
base = u.split("/mcp")[0]; break
|
|
52
|
-
base = (base or "http://
|
|
51
|
+
base = (base or os.environ.get("WLKJ_BASE_URL", "http://10.89.7.120")).rstrip("/")
|
|
53
52
|
token = os.environ.get("WLKJ_MCP_TOKEN", "")
|
|
54
53
|
if not token:
|
|
55
54
|
for srv in (cfg.get("mcpServers") or {}).values():
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
python switch_project.py --email a@b.c --pw xxx # 非交互:直接指定
|
|
7
7
|
python switch_project.py --project-uuid <UUID> # 非交互:直接指定项目
|
|
8
8
|
|
|
9
|
-
需工作台后端在跑(默认 http://
|
|
9
|
+
需工作台后端在跑(默认 http://10.89.7.120 云)。
|
|
10
10
|
"""
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
@@ -14,16 +14,15 @@ import sys
|
|
|
14
14
|
import urllib.request
|
|
15
15
|
import urllib.error
|
|
16
16
|
|
|
17
|
-
# 宿主根:脚本部署在 {host}/scripts/domain/kg/,4 级 dirname = {host}(.qoder
|
|
17
|
+
# 宿主根:脚本部署在 {host}/scripts/domain/kg/,4 级 dirname = {host}(.qoder)
|
|
18
18
|
HOST_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
19
19
|
REPO_ROOT = os.path.dirname(HOST_DIR) # qoderAll/
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def _find_config_path():
|
|
23
|
-
"""按宿主探测 mcp_config.json:当前宿主根 →
|
|
23
|
+
"""按宿主探测 mcp_config.json:当前宿主根 → .qoder。"""
|
|
24
24
|
for p in (
|
|
25
|
-
os.path.join(HOST_DIR, "mcp_config.json"),
|
|
26
|
-
os.path.join(REPO_ROOT, "wlinkj-workflow", "mcp_config.json"),
|
|
25
|
+
os.path.join(HOST_DIR, "mcp_config.json"), # 当前宿主根
|
|
27
26
|
os.path.join(REPO_ROOT, ".qoder", "mcp_config.json"),
|
|
28
27
|
):
|
|
29
28
|
if os.path.isfile(p):
|
|
@@ -34,7 +33,7 @@ def _find_config_path():
|
|
|
34
33
|
CONFIG_PATH = _find_config_path()
|
|
35
34
|
|
|
36
35
|
# 平台地址(默认 10010,可 env 覆盖)
|
|
37
|
-
BASE_URL = os.environ.get("WLKJ_BASE_URL", "http://
|
|
36
|
+
BASE_URL = os.environ.get("WLKJ_BASE_URL", "http://10.89.7.120")
|
|
38
37
|
|
|
39
38
|
|
|
40
39
|
def bind(email, password, base_url=BASE_URL):
|
|
@@ -51,7 +51,7 @@ def _get_base(cfg: dict) -> str:
|
|
|
51
51
|
u = srv.get("url", "")
|
|
52
52
|
if "/mcp" in u:
|
|
53
53
|
return u.split("/mcp")[0]
|
|
54
|
-
return "http://
|
|
54
|
+
return os.environ.get("WLKJ_BASE_URL", "http://10.89.7.120")
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
def _api(base, method, path, token, body=None):
|
|
Binary file
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""present_board.py — 知识层富展示看板(cap.present.widget / 落盘降级)。
|
|
3
|
+
|
|
4
|
+
子命令(数据走 MCP,HTML 自包含,复用 capability.present_html.present):
|
|
5
|
+
impact — get_impact 影响传播树(分层 upstream/downstream 可折叠)
|
|
6
|
+
coverage — coverage_matrix 覆盖热力图(功能×测试 有/无)
|
|
7
|
+
feature — feature_overview 功能画像卡(端点+按钮+用例+页面+PRD)
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
python present_board.py impact --entity handleExport --project-id <UUID>
|
|
11
|
+
python present_board.py coverage --project-id <UUID>
|
|
12
|
+
python present_board.py feature --feature 资产管理 --project-id <UUID>
|
|
13
|
+
|
|
14
|
+
QoderWork 桌面端 → HTML 嵌入对话流;CLI/IDE → 落盘 workspace/members/{dev}/journal/。
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_repo():
|
|
24
|
+
env = os.environ.get("QODER_REPO") or os.environ.get("WLKJ_REPO")
|
|
25
|
+
if env:
|
|
26
|
+
return Path(env)
|
|
27
|
+
p = Path(__file__).resolve()
|
|
28
|
+
for parent in p.parents:
|
|
29
|
+
if (parent / ".qoder").is_dir() or (parent / "packages").is_dir():
|
|
30
|
+
return parent
|
|
31
|
+
return p.parent
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cap():
|
|
35
|
+
repo = _resolve_repo()
|
|
36
|
+
sd = str(repo / ".qoder" / "scripts")
|
|
37
|
+
if sd not in sys.path:
|
|
38
|
+
sys.path.insert(0, sd)
|
|
39
|
+
from capability.registry import resolve
|
|
40
|
+
return resolve(), repo
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _unpack(r):
|
|
44
|
+
"""cap.mcp.call 返回 ToolResult(.text 是 JSON 字符串),解包成 dict。"""
|
|
45
|
+
if isinstance(r, dict):
|
|
46
|
+
return r
|
|
47
|
+
if getattr(r, "is_error", False):
|
|
48
|
+
return {"error": getattr(r, "text", str(r))}
|
|
49
|
+
txt = getattr(r, "text", None)
|
|
50
|
+
if txt is None:
|
|
51
|
+
return r
|
|
52
|
+
try:
|
|
53
|
+
return json.loads(txt)
|
|
54
|
+
except Exception:
|
|
55
|
+
return {"raw": txt}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_CSS = """
|
|
59
|
+
body{font-family:-apple-system,'Segoe UI',sans-serif;margin:0;padding:20px;background:#f7f8fa;color:#1f2329}
|
|
60
|
+
h1{font-size:20px;margin:0 0 8px} h3{font-size:14px;margin:12px 0 6px;color:#4e5969}
|
|
61
|
+
p{color:#86909c;font-size:13px;margin:4px 0}
|
|
62
|
+
details{background:#fff;border:1px solid #e5e6eb;border-radius:6px;padding:8px 12px;margin:6px 0}
|
|
63
|
+
summary{cursor:pointer;font-weight:600;font-size:13px}
|
|
64
|
+
ul{margin:6px 0;padding-left:20px} li{font-size:13px;line-height:1.8} small{color:#86909c}
|
|
65
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px}
|
|
66
|
+
.cell{padding:10px;border-radius:6px;font-size:13px;text-align:center}
|
|
67
|
+
.covered{background:#e8ffea;color:#00b42a;border:1px solid #b9f0c6}
|
|
68
|
+
.gap{background:#fff2f0;color:#f53f3f;border:1px solid #ffd4d4}
|
|
69
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
|
|
70
|
+
.card{background:#fff;border:1px solid #e5e6eb;border-radius:8px;padding:12px}
|
|
71
|
+
.card h3{margin-top:0}
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _wrap(title, body):
|
|
76
|
+
return ("<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
|
77
|
+
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
78
|
+
"<title>%s</title><style>%s</style></head><body>%s</body></html>"
|
|
79
|
+
% (title, _CSS, body))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_impact(args):
|
|
83
|
+
cap, repo = _cap()
|
|
84
|
+
impact = _unpack(cap.mcp.call("get_impact", {"project_id": args.project_id or "",
|
|
85
|
+
"entity": args.entity, "depth": args.depth}))
|
|
86
|
+
from capability.present_html import present
|
|
87
|
+
if "error" in impact:
|
|
88
|
+
body = "<h1>影响分析 · %s</h1><p class='gap'>取数失败: %s</p>" % (args.entity, impact["error"][:200])
|
|
89
|
+
present(_wrap("影响分析-" + args.entity, body), name="impact-" + args.entity[:20], repo_root=repo)
|
|
90
|
+
return
|
|
91
|
+
sections = []
|
|
92
|
+
for direction, label in [("upstream", "↑ 谁依赖我(改动影响范围)"),
|
|
93
|
+
("downstream", "↓ 我依赖谁")]:
|
|
94
|
+
layers = impact.get(direction) or []
|
|
95
|
+
items_html = ""
|
|
96
|
+
for i, lay in enumerate(layers):
|
|
97
|
+
its = lay.get("items", []) if isinstance(lay, dict) else []
|
|
98
|
+
lis = "".join("<li>%s <small>%s</small></li>"
|
|
99
|
+
% (it.get("id", "?"), it.get("type", "")) for it in its)
|
|
100
|
+
items_html += "<details open><summary>第%d层 (%d)</summary><ul>%s</ul></details>" % (i + 1, len(its), lis)
|
|
101
|
+
sections.append("<h3>%s</h3>%s" % (label, items_html))
|
|
102
|
+
totals = impact.get("totals", {}) or {}
|
|
103
|
+
bytype = impact.get("byType", {}) or {}
|
|
104
|
+
body = ("<h1>影响分析 · %s</h1><p>节点 %s · 类型分布 %s · depth %d</p>"
|
|
105
|
+
% (args.entity, totals.get("nodes", 0),
|
|
106
|
+
json.dumps(bytype, ensure_ascii=False), args.depth)) + "".join(sections)
|
|
107
|
+
present(_wrap("影响分析-" + args.entity, body),
|
|
108
|
+
name="impact-" + args.entity[:20], repo_root=repo)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_coverage(args):
|
|
112
|
+
cap, repo = _cap()
|
|
113
|
+
cov = _unpack(cap.mcp.call("coverage_matrix", {"project_id": args.project_id or ""}))
|
|
114
|
+
from capability.present_html import present
|
|
115
|
+
if "error" in cov:
|
|
116
|
+
present(_wrap("覆盖矩阵", "<h1>测试覆盖矩阵</h1><p class='gap'>取数失败: %s</p>" % cov["error"][:200]),
|
|
117
|
+
name="coverage", repo_root=repo)
|
|
118
|
+
return
|
|
119
|
+
items = cov.get("items") or cov.get("matrix") or cov.get("features") or []
|
|
120
|
+
cells = "".join(_cov_cell(it) for it in items) or "<p>无覆盖数据</p>"
|
|
121
|
+
covered = sum(1 for it in items if it.get("has_test") or it.get("covered"))
|
|
122
|
+
body = ("<h1>测试覆盖矩阵</h1><p>共 %d 项 · 已覆盖 %d · 缺口 %d</p>"
|
|
123
|
+
% (len(items), covered, len(items) - covered)) + '<div class="grid">%s</div>' % cells
|
|
124
|
+
present(_wrap("覆盖矩阵", body), name="coverage", repo_root=repo)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _cov_cell(it):
|
|
128
|
+
name = it.get("feature") or it.get("name") or it.get("canonical") or "?"
|
|
129
|
+
has = it.get("has_test") or it.get("covered")
|
|
130
|
+
cls = "covered" if has else "gap"
|
|
131
|
+
return '<div class="cell %s">%s</div>' % (cls, name)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_feature(args):
|
|
135
|
+
cap, repo = _cap()
|
|
136
|
+
feat = _unpack(cap.mcp.call("feature_overview", {"project_id": args.project_id or "",
|
|
137
|
+
"feature": args.feature}))
|
|
138
|
+
from capability.present_html import present
|
|
139
|
+
if "error" in feat:
|
|
140
|
+
present(_wrap("功能画像-" + args.feature, "<h1>功能画像 · %s</h1><p class='gap'>取数失败: %s</p>" % (args.feature, feat["error"][:200])),
|
|
141
|
+
name="feature-" + args.feature[:20], repo_root=repo)
|
|
142
|
+
return
|
|
143
|
+
cards = []
|
|
144
|
+
for key, label in [("endpoints", "端点"), ("buttons", "按钮"),
|
|
145
|
+
("tests", "测试用例"), ("pages", "页面"), ("prds", "PRD")]:
|
|
146
|
+
lst = feat.get(key) or []
|
|
147
|
+
if not lst:
|
|
148
|
+
continue
|
|
149
|
+
lis = "".join("<li>%s</li>" % x for x in (lst if isinstance(lst, list) else [str(lst)])[:20])
|
|
150
|
+
cards.append('<div class="card"><h3>%s (%d)</h3><ul>%s</ul></div>' % (label, len(lst), lis))
|
|
151
|
+
complete = "完整" if len(cards) >= 4 else "部分"
|
|
152
|
+
body = ("<h1>功能画像 · %s</h1><p>覆盖 %d 维 · %s画像</p>"
|
|
153
|
+
% (args.feature, len(cards), complete)) + '<div class="cards">%s</div>' % "".join(cards)
|
|
154
|
+
present(_wrap("功能画像-" + args.feature, body),
|
|
155
|
+
name="feature-" + args.feature[:20], repo_root=repo)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main():
|
|
159
|
+
ap = argparse.ArgumentParser(description="知识层富展示看板")
|
|
160
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
161
|
+
p_imp = sub.add_parser("impact")
|
|
162
|
+
p_imp.add_argument("--entity", required=True)
|
|
163
|
+
p_imp.add_argument("--project-id")
|
|
164
|
+
p_imp.add_argument("--depth", type=int, default=2)
|
|
165
|
+
p_cov = sub.add_parser("coverage")
|
|
166
|
+
p_cov.add_argument("--project-id")
|
|
167
|
+
p_fea = sub.add_parser("feature")
|
|
168
|
+
p_fea.add_argument("--feature", required=True)
|
|
169
|
+
p_fea.add_argument("--project-id")
|
|
170
|
+
args = ap.parse_args()
|
|
171
|
+
if args.cmd == "impact":
|
|
172
|
+
cmd_impact(args)
|
|
173
|
+
elif args.cmd == "coverage":
|
|
174
|
+
cmd_coverage(args)
|
|
175
|
+
elif args.cmd == "feature":
|
|
176
|
+
cmd_feature(args)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
main()
|
|
@@ -68,6 +68,16 @@
|
|
|
68
68
|
]
|
|
69
69
|
}
|
|
70
70
|
],
|
|
71
|
+
"PreToolUse": [
|
|
72
|
+
{
|
|
73
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
74
|
+
"hooks": [
|
|
75
|
+
{
|
|
76
|
+
"command": "python .qoder/hooks/pre-tool-use.py || python3 .qoder/hooks/pre-tool-use.py"
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
],
|
|
71
81
|
"PostToolUse": [
|
|
72
82
|
{
|
|
73
83
|
"matcher": "*",
|