@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.
Files changed (32) hide show
  1. package/package.json +2 -2
  2. package/templates/.qoder/.runtime/hook-errors.log +4 -0
  3. package/templates/qoder/commands/optional/wl-status.md +2 -0
  4. package/templates/qoder/commands/wl-code.md +45 -10
  5. package/templates/qoder/commands/wl-init.md +129 -129
  6. package/templates/qoder/commands/wl-prd.md +27 -12
  7. package/templates/qoder/commands/wl-search.md +32 -0
  8. package/templates/qoder/commands/wl-task.md +8 -4
  9. package/templates/qoder/commands/wl-test.md +4 -0
  10. package/templates/qoder/hooks/post-tool-use.py +31 -1
  11. package/templates/qoder/hooks/pre-tool-use.py +136 -0
  12. package/templates/qoder/hooks/session-start.py +365 -365
  13. package/templates/qoder/scripts/capability/__pycache__/present_html.cpython-39.pyc +0 -0
  14. package/templates/qoder/scripts/capability/__pycache__/registry.cpython-39.pyc +0 -0
  15. package/templates/qoder/scripts/capability/caps/__init__.py +4 -4
  16. package/templates/qoder/scripts/capability/caps/__pycache__/__init__.cpython-39.pyc +0 -0
  17. package/templates/qoder/scripts/capability/caps/__pycache__/notify.cpython-39.pyc +0 -0
  18. package/templates/qoder/scripts/capability/caps/__pycache__/sandbox.cpython-39.pyc +0 -0
  19. package/templates/qoder/scripts/capability/caps/notify.py +64 -0
  20. package/templates/qoder/scripts/capability/caps/sandbox.py +38 -0
  21. package/templates/qoder/scripts/capability/present_html.py +68 -0
  22. package/templates/qoder/scripts/capability/registry.py +6 -2
  23. package/templates/qoder/scripts/capability/registry_mcp.py +4 -6
  24. package/templates/qoder/scripts/domain/integration/__pycache__/return_to_platform.cpython-39.pyc +0 -0
  25. package/templates/qoder/scripts/domain/integration/return_to_platform.py +9 -6
  26. package/templates/qoder/scripts/domain/integration/spec_upload.py +1 -2
  27. package/templates/qoder/scripts/domain/kg/switch_project.py +5 -6
  28. package/templates/qoder/scripts/engine/poller.py +1 -1
  29. package/templates/qoder/scripts/validation/metrics/__pycache__/present_board.cpython-39.pyc +0 -0
  30. package/templates/qoder/scripts/validation/metrics/present_board.py +180 -0
  31. package/templates/qoder/settings.json +10 -0
  32. package/templates/qoder/scripts/domain/kg/extract/extract.py.bak +0 -430
@@ -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
  ]
@@ -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
- return NoOpNotifyCap()
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
- return NoOpSandboxCap()
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
- # 现在: 优先读 wlinkj-workflow/mcp_config.json 的 mcpServers,读不到才 fallback。
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://127.0.0.1:10010/mcp",
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
- """定位 wlinkj-workflow/mcp_config.json(在仓库根下)。"""
76
+ """定位 mcp_config.json(在仓库根/宿主下)。"""
77
77
  repo = _get_repo_root()
78
- # registry_mcp.py 在 .../scripts/capability/, mcp_config.json 在 wlinkj-workflow/
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():
@@ -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
- wlinkj-workflow 下 0 匹配 —— 引擎根本不知道有回流这回事。
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
- """读 wlinkj-workflow/mcp_config.json,失败返回空 dict。"""
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 urlhttp://127.0.0.1:10010),从 mcp_config server 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://127.0.0.1:10010"
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) -> dict:
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://127.0.0.1:10010").rstrip("/")
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://127.0.0.1:10010)。
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 或 wlinkj-workflow
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:当前宿主根 → wlinkj-workflow → .qoder(适配多宿主部署)。"""
23
+ """按宿主探测 mcp_config.json:当前宿主根 → .qoder"""
24
24
  for p in (
25
- os.path.join(HOST_DIR, "mcp_config.json"), # 当前宿主根(.qoder 或 wlinkj-workflow)
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://127.0.0.1:10010")
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://127.0.0.1:10010"
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):
@@ -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": "*",