热重载前置探活+skill跨根找真身:mcpctl对调后先_live_oc探活(WSL未跑/OC未监听则跳过重载按成功返回,不再报WinError10061失败,host复用给推送与reload),ocskill新增_alt_skill_md跨已知根(.workbuddy/.config含trash/WSL对应根,OC_SKILL_ALT_ROOTS可追加)找同名SKILL.md真身,WSL二层空结果补说明行不静默,两套单测全绿(6项+15项)

This commit is contained in:
ypshidifu
2026-09-25 10:18:28 +08:00
parent baf169ec19
commit ac47b7a2ed
5 changed files with 303 additions and 29 deletions
+22 -2
View File
@@ -139,6 +139,14 @@ python mcpctl.py off --no-reload # 只对调文件,不调用重载
热重载打的是 `POST /api/location/reload`,会重建 location 并重读磁盘配置,MCP 立即生效; 热重载打的是 `POST /api/location/reload`,会重建 location 并重读磁盘配置,MCP 立即生效;
失败时配置文件已经对调完成,重启 OC 一样生效。 失败时配置文件已经对调完成,重启 OC 一样生效。
**前置探活:没有运行中的 OC 就不重载、也不算失败(2026-09-25 用户反馈)**:
对调完成后先 `_live_oc()` —— WSL 目标先查 VM 是否在跑(没跑直接跳过、不发网络请求),
再端口发现 + 探活找运行中的 OC。都没有 → 打印「无需热重载:… 配置已对调完成,
等 OC 下次启动时会自动加载新配置」并按成功返回,**不再报**
「热重载失败:… WinError 10061 连接拒绝」(WSL/OC 都没启动时冷启动会自己加载)。
探活命中的 host 直接复用给推送兜底与 reload(不重复探测);只有
「OC 在跑但 reload 返回 401/404/错误」才照旧算真实失败。
**开启方向多了「推送兜底」(双保险)**:`on` 在对调+写 flag 之后、reload 之前, **开启方向多了「推送兜底」(双保险)**:`on` 在对调+写 flag 之后、reload 之前,
先把活配置里的 `mcp` 推进**运行中的 OC**。三端同一套语义——**GET+merge 补 shell 先把活配置里的 `mcp` 推进**运行中的 OC**。三端同一套语义——**GET+merge 补 shell
→ PATCH 真实路由**,按各自运行方式实现: → PATCH 真实路由**,按各自运行方式实现:
@@ -188,10 +196,22 @@ UNC 目录扫描(那会触发冷启动卡死),直接报错返回。
- 目标读不出来时不再用 realpath 硬解(那只会解出链接自身、误报断链), - 目标读不出来时不再用 realpath 硬解(那只会解出链接自身、误报断链),
直接标注「疑似 WSL 内软链接,交由二层诊断」; 直接标注「疑似 WSL 内软链接,交由二层诊断」;
- 推送失败(缺 SKILL.md)的报错里同样附带这份诊断,回答「真实文件在哪」。 - 推送失败(缺 SKILL.md)的报错里同样附带这份诊断,回答「真实文件在哪」。
- **跨根兜底:空壳目录直接找出「真身在别处」(2026-09-25 用户反馈)**——
实测来源目录(`.config/opencode/skills/<name>`,两端)是**真实空目录、不是链接**,
但大模型照样能调用:真身在另一个根 `~/.workbuddy/skills/<name>/SKILL.md`
(即 `wb` 终端)。结论行不再止步「仍未找到」,而是跨已知根找同名 SKILL.md:
本机 `~/.workbuddy/skills`、`~/.config/opencode/skills`(含 `.trash` 回收站)、
当前目录 `.workbuddy/skills`、WSL 端对应两根(folder 是 WSL UNC 直查,
否则 VM 在跑才查),并可用环境变量 `OC_SKILL_ALT_ROOTS`(`os.pathsep` 分隔)
追加自定义根;找到就列出真身路径并提示「改选包含真身的目录(如 wb 终端)作来源」。
另外 WSL 二层查完**空结果也会说话**(「ℹ WSL 内二层已查:没有 SKILL.md、也没有链接」),
不再静默——静默会被误读成二层没跑。
**回归单测**:`python _test_diag.py`(空壳 / 正常 skill / junction 链接 / 断链 / **回归单测**:`python _test_diag.py`(空壳 / 正常 skill / junction 链接 / 断链 /
大小写 / WSL UNC 真链接共 15 项断言,2 个文件 symlink 场景在拦截 symlink 的 大小写 / WSL UNC 真链接 / 跨根兜底等 15+ 项断言,2 个文件 symlink 场景在拦截 symlink 的
机器上自动 SKIP;含 WSL 场景,首次跑会拉起 VM)。 机器上自动 SKIP;含 WSL 场景,首次跑会拉起 VM);
`python _test_mcpctl.py`(热重载前置探活 6 项:未运行 skip 不探测、失败仍报错、
host 复用等)。
--- ---
+37
View File
@@ -9,6 +9,7 @@ from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ocskill import ( # noqa: E402 from ocskill import ( # noqa: E402
diag_skill_md, skill_brief, probe_push, find_skill_md, _wsl_running, diag_skill_md, skill_brief, probe_push, find_skill_md, _wsl_running,
_alt_skill_md,
) )
FAIL: list[str] = [] FAIL: list[str] = []
@@ -131,6 +132,9 @@ if unc.exists():
notes = diag_skill_md(unc) notes = diag_skill_md(unc)
check("WSL UNC 空壳 -> 报空的/仍未找到", any(("目录是空的" in n) or ("仍未找到" in n) for n in notes), check("WSL UNC 空壳 -> 报空的/仍未找到", any(("目录是空的" in n) or ("仍未找到" in n) for n in notes),
str(notes)) str(notes))
if _wsl_running("Ubuntu-24.04"):
check("WSL UNC 空壳 -> 二层已查有说明行(不静默)",
any("WSL 内二层" in n for n in notes), str(notes))
else: else:
print("SKIP WSL UNC 空壳(路径不存在):", unc) print("SKIP WSL UNC 空壳(路径不存在):", unc)
@@ -170,6 +174,39 @@ res = probe_push(str(empty))
check("probe_push 空壳 -> ok=False 且带 diag", res.get("ok") is False and res.get("diag"), check("probe_push 空壳 -> ok=False 且带 diag", res.get("ok") is False and res.get("diag"),
str(res)[:300]) str(res)[:300])
# ---------- 11. 空壳但同名 skill 真身在别的根(2026-09-25 用户反馈) ----------
# 真实场景:来源目录是空壳,但大模型能调用 —— 真身在 ~/.workbuddy/skills(wb 终端)。
# 测试用 OC_SKILL_ALT_ROOTS 注入一个「另一个根」,避免往真实 wb 目录里写文件。
altbase = tmp / "alt_roots"
(altbase / "alt-skill").mkdir(parents=True)
(altbase / "alt-skill" / "SKILL.md").write_text("---\nname: alt-skill\ndescription: d\nt\n---\n",
encoding="utf-8")
shell2 = tmp / "alt-skill"
shell2.mkdir()
os.environ["OC_SKILL_ALT_ROOTS"] = str(altbase)
try:
notes = diag_skill_md(shell2)
finally:
os.environ.pop("OC_SKILL_ALT_ROOTS", None)
check("空壳 + 其它根有真身 -> 结论给出真身路径",
any("真身在别处" in n for n in notes)
and any(str(altbase / "alt-skill" / "SKILL.md") in n for n in notes),
str(notes))
b = skill_brief("alt-skill", tmp)
check("skill_brief 空壳仍 has_md=False(真身只在诊断里提示)",
b.get("has_md") is False and b.get("diag"), str(b)[:200])
# ---------- 12. 跨端/回收站兜底(环境依赖,不满足就 SKIP) ----------
gkd = Path.home() / ".config" / "opencode" / "skills" / "gkd-shizuku-keepalive"
gkd_alts = _alt_skill_md(gkd) if gkd.is_dir() and find_skill_md(gkd) is None else []
if gkd_alts:
notes = diag_skill_md(gkd)
check("本机空壳 -> 找到回收站/跨端里的真身",
any("真身在别处" in n for n in notes) and any(gkd_alts[0] in n for n in notes),
str(notes))
else:
print("SKIP 跨端回收站兜底(环境不满足:目录不存在/非空壳/无处可找)")
print() print()
print(("ALL PASS" if not FAIL else "FAILURES: " + ", ".join(FAIL))) print(("ALL PASS" if not FAIL else "FAILURES: " + ", ".join(FAIL)))
sys.exit(1 if FAIL else 0) sys.exit(1 if FAIL else 0)
+84
View File
@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""mcpctl 热重载前置探活单测(2026-09-25 用户反馈)。
用户场景:mcp↔wsl 开关对调后,WSL/OC 没在跑 → 报「热重载失败:… WinError 10061
连接拒绝」。期望:没有可重载的 OC 就跳过重载、按成功返回(下次冷启动自动加载),
只有「OC 在跑但重载出错」才算失败。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import mcpctl # noqa: E402
FAIL: list[str] = []
def check(name: str, cond: bool, extra: str = "") -> None:
print(("PASS " if cond else "FAIL ") + name + (" | " + extra if extra else ""))
if not cond:
FAIL.append(name)
class _A: # 最小 args 替身(只用到 _live_oc/do_reload 需要的字段)
distro = "Ubuntu-24.04"
password = ""
port = 4096
timeout = 2.0
args = _A()
_orig = (mcpctl._wsl_running, mcpctl._find_live_host, mcpctl._post)
def _no_net(*_a, **_k):
raise AssertionError("这一步不该做网络探测")
try:
# ---------- 1. WSL 目标 + VM 未运行 -> 直接 skip,绝不探测 ----------
mcpctl._wsl_running = lambda d: False
mcpctl._find_live_host = _no_net
h, pwd, skip = mcpctl._live_oc("wsl", args)
check("WSL 未运行 -> skip 且不做探测",
h == "" and bool(skip) and "WSL 未启动" in skip, str(skip))
# ---------- 2. WSL 在跑但 OC 没在跑 -> skip 带探测原因(不再是「失败」) ----------
mcpctl._wsl_running = lambda d: True
mcpctl._find_live_host = (
lambda t, a, pw: ("", "/root/.config/opencode", "http://172.29.95.185:4096 连不上")
)
h, pwd, skip = mcpctl._live_oc("wsl", args)
check("OC 未运行 -> skip 带原因", h == "" and bool(skip) and "连不上" in skip, str(skip))
# ---------- 3. OC 在跑 -> 不 skip,host 直接复用 ----------
mcpctl._find_live_host = (
lambda t, a, pw: ("http://127.0.0.1:4096", "/root/.config/opencode", "")
)
h, pwd, skip = mcpctl._live_oc("wsl", args)
check("OC 运行中 -> 不 skip 且拿到 host",
skip is None and h.startswith("http"), str((h, skip)))
# ---------- 4. do_reload 复用 host -> 不再探测,直接 reload 成功 ----------
mcpctl._find_live_host = _no_net
mcpctl._post = lambda url, pw, timeout, data=b"{}", method="POST": (200, "{}")
ok, msg = mcpctl.do_reload("wsl", args, host="http://127.0.0.1:4096", password="x")
check("do_reload 复用 host -> 热重载成功", ok and "热重载成功" in msg, msg)
# ---------- 5. OC 在跑但 reload 返回错误 -> 仍算真实失败(不能吞) ----------
mcpctl._post = lambda url, pw, timeout, data=b"{}", method="POST": (0, "boom")
ok, msg = mcpctl.do_reload("wsl", args, host="http://127.0.0.1:4096", password="x")
check("OC 在跑但重载连不上 -> 仍报失败", (not ok) and "连不上" in msg, msg)
# ---------- 6. 本机目标 OC 没在跑 -> skip(本机目标不看 WSL 状态) ----------
mcpctl._find_live_host = (
lambda t, a, pw: ("", "C:/cfg", "没有找到运行中的 OC(4096 未监听,端口发现也未命中)")
)
h, pwd, skip = mcpctl._live_oc("local", args)
check("local OC 未运行 -> skip", h == "" and bool(skip) and "没有找到" in skip, str(skip))
finally:
mcpctl._wsl_running, mcpctl._find_live_host, mcpctl._post = _orig
print()
print(("ALL PASS" if not FAIL else "FAILURES: " + ", ".join(FAIL)))
sys.exit(1 if FAIL else 0)
+65 -23
View File
@@ -5,8 +5,8 @@ mcpctl —— 命令行版的 MCP 配置对调开关(与 TUI 插件 / server
做什么: 做什么:
status 查看当前模式与 MCP 开/关状态(只读,不改任何文件) status 查看当前模式与 MCP 开/关状态(只读,不改任何文件)
on 把配置对调为「含 MCP」的原始版,并调用 reload 热重载 on 把配置对调为「含 MCP」的原始版,OC 在跑则热重载(没跑就跳过)
off 把配置对调为「不含 MCP」的安静版,并调用 reload 热重载 off 把配置对调为「不含 MCP」的安静版,OC 在跑则热重载(没跑就跳过)
mode 查看或修改 mcpcfg.txt 模式键(自动 / 手动 / 关闭) mode 查看或修改 mcpcfg.txt 模式键(自动 / 手动 / 关闭)
模式语义(与插件完全一致): 模式语义(与插件完全一致):
@@ -551,14 +551,17 @@ def _find_live_host(target: str, args, password: str) -> tuple[str, str, str]:
return "", directory, last or "没有找到运行中的 OC(4096 未监听,端口发现也未命中)" return "", directory, last or "没有找到运行中的 OC(4096 未监听,端口发现也未命中)"
def do_reload(target: str, args) -> tuple[bool, str]: def do_reload(target: str, args, host: str | None = None,
password = args.password or _read_password( password: str | None = None) -> tuple[bool, str]:
wsl_config_dir(args.distro) if target == "wsl" else _home_config() if password is None:
) password = args.password or _read_password(
# 端口发现 + 探活:先找到真正在跑的 OC(4096 是默认值,实测常是随机端口) wsl_config_dir(args.distro) if target == "wsl" else _home_config()
host, _dir, why = _find_live_host(target, args, password) )
if not host: if not host:
return False, why # 端口发现 + 探活:先找到真正在跑的 OC(4096 是默认值,实测常是随机端口)
host, _dir, why = _find_live_host(target, args, password)
if not host:
return False, why
code, body = _post(host + "/api/location/reload", password, args.timeout) code, body = _post(host + "/api/location/reload", password, args.timeout)
if 200 <= code < 300: if 200 <= code < 300:
return True, f"热重载成功({host}/api/location/reload)" return True, f"热重载成功({host}/api/location/reload)"
@@ -571,7 +574,8 @@ def do_reload(target: str, args) -> tuple[bool, str]:
return False, f"{host} 连不上(OC 可能没在运行)" return False, f"{host} 连不上(OC 可能没在运行)"
def push_mcp_config(args) -> tuple[bool, str]: def push_mcp_config(args, host: str | None = None,
password: str | None = None) -> tuple[bool, str]:
"""reload 之前的兜底:把活配置里的 mcp 直接 PATCH 进运行中的 OC。 """reload 之前的兜底:把活配置里的 mcp 直接 PATCH 进运行中的 OC。
与 tui.js / ai-mcp.ts 的 pushMcpConfig 同一套链: 与 tui.js / ai-mcp.ts 的 pushMcpConfig 同一套链:
端口发现 + GET /api/config?directory= 探活并取 shell(GET+merge 三级兜底) 端口发现 + GET /api/config?directory= 探活并取 shell(GET+merge 三级兜底)
@@ -590,13 +594,18 @@ def push_mcp_config(args) -> tuple[bool, str]:
if not isinstance(mcp, dict) or not ((mcp.get("servers") or {})): if not isinstance(mcp, dict) or not ((mcp.get("servers") or {})):
return False, "活配置里没有 mcp.servers,无可推送内容" return False, "活配置里没有 mcp.servers,无可推送内容"
password = args.password or _read_password( if password is None:
wsl_config_dir(args.distro) if args.target == "wsl" else _home_config() password = args.password or _read_password(
) wsl_config_dir(args.distro) if args.target == "wsl" else _home_config()
)
# 1) 端口发现 + 探活:GET /api/config 200 = 命中运行中的 OC(4096 常无监听) # 1) 端口发现 + 探活:GET /api/config 200 = 命中运行中的 OC(4096 常无监听)
host, directory, why = _find_live_host(args.target, args, password) # 调用方(_switch)已探活时直接复用 host,不重复探测
if not host: if host:
return False, why directory = _cfg_directory(args.target, args.distro)
else:
host, directory, why = _find_live_host(args.target, args, password)
if not host:
return False, why
encoded = urllib.parse.quote(directory, safe=":/") encoded = urllib.parse.quote(directory, safe=":/")
# 2) GET+merge 补 shell:GET 运行中 config 的 info.shell → 活文件 shell → null # 2) GET+merge 补 shell:GET 运行中 config 的 info.shell → 活文件 shell → null
@@ -678,6 +687,31 @@ def cmd_status(args) -> int:
return 0 if not cfg.get("error") else 2 return 0 if not cfg.get("error") else 2
def _live_oc(target: str, args) -> tuple[str, str, str | None]:
"""热重载前置探活(2026-09-25 用户反馈新增)。
返回 (host, password, skip_reason):
skip_reason 为 None → host 是运行中的 OC,可直接热重载(复用,不二探);
skip_reason 非 None → 没有可重载的 OC,调用方打印原因后按成功返回 ——
WSL 没启动 / OC 没在跑时,配置文件已经对调好,下次冷启动 OC 自己就会
加载,绝不能报「热重载失败」(如 WinError 10061 连接拒绝)。
"""
if target == "wsl":
try:
wsl_on = _wsl_running(args.distro)
except Exception:
wsl_on = False
if not wsl_on:
return "", "", "WSL 未启动(OC 自然也没在运行)"
password = args.password or _read_password(
wsl_config_dir(args.distro) if target == "wsl" else _home_config()
)
host, _dir, why = _find_live_host(target, args, password)
if not host:
return "", password, (why or "没有找到运行中的 OC")
return host, password, None
def _switch(args, want_on: bool) -> int: def _switch(args, want_on: bool) -> int:
# 写操作:WSL 没跑先唤醒(gate 在任何 UNC .exists() 之前) # 写操作:WSL 没跑先唤醒(gate 在任何 UNC .exists() 之前)
err = wsl_gate(args, need_write=True) err = wsl_gate(args, need_write=True)
@@ -720,22 +754,30 @@ def _switch(args, want_on: bool) -> int:
print(f"配置已对调,MCP 现在是「{'开启' if after else '关闭'}」。") print(f"配置已对调,MCP 现在是「{'开启' if after else '关闭'}」。")
if args.no_reload:
print("已按 --no-reload 跳过热重载,需重启 OC 生效。")
return 0
# 热重载前置探活(2026-09-25 用户反馈):WSL 没启动 / OC 没在跑就不重载、
# 也不算失败 —— 配置文件已对调好,下次冷启动 OC 自己就会加载
host, pwd, skip = _live_oc(args.target, args)
if skip:
print(f"无需热重载:{skip}。")
print(" 配置已对调完成,等 OC 下次启动时会自动加载新配置。")
return 0
# 兜底推送(仅开启方向):reload 之前先把 mcp 配置送进运行中的 OC, # 兜底推送(仅开启方向):reload 之前先把 mcp 配置送进运行中的 OC,
# 推送成功 = 双保险;推送失败不阻断,走 reload 主通道。 # 推送成功 = 双保险;推送失败不阻断,走 reload 主通道。
# 推送失败不在这里就报负面消息 —— 它只是额外的双保险通道, # 推送失败不在这里就报负面消息 —— 它只是额外的双保险通道,
# 是否真的生效由 reload 结果决定(避免「MCP 已可用却提示兜底」误导用户)。 # 是否真的生效由 reload 结果决定(避免「MCP 已可用却提示兜底」误导用户)。
pushed = None pushed = None
pmsg = "" pmsg = ""
if want_on and not args.no_reload: if want_on:
pushed, pmsg = push_mcp_config(args) pushed, pmsg = push_mcp_config(args, host=host, password=pwd)
if pushed: if pushed:
print(f"mcp 配置已先推送进 OC:{pmsg}") print(f"mcp 配置已先推送进 OC:{pmsg}")
if args.no_reload: ok, msg = do_reload(args.target, args, host=host, password=pwd)
print("已按 --no-reload 跳过热重载,需重启 OC 生效。")
return 0
ok, msg = do_reload(args.target, args)
if ok: if ok:
if pushed: if pushed:
print(f"{msg},mcp 配置已先送入 + 热重载(双保险)。") print(f"{msg},mcp 配置已先送入 + 热重载(双保险)。")
+95 -4
View File
@@ -736,6 +736,79 @@ def _resolve_link_real(path: Path, target: str) -> Path | None:
return None return None
def _alt_skill_md(folder) -> list[str]:
"""空壳/断链目录时,跨已知 skill 根目录找同名 skill 的 SKILL.md 真身。
2026-09-25 用户实测场景:来源目录(如 .config/opencode/skills/<name>)两端
都是空壳、也没有任何链接,但大模型照样能调用该 skill —— 因为真身在另一个
根(~/.workbuddy/skills/<name>/SKILL.md,即 wb 终端)。诊断必须把「原始文件
位置」找出来,而不是只报「仍未找到」。
搜索根(可用环境变量 OC_SKILL_ALT_ROOTS 以 os.pathsep 追加):
本机 ~/.workbuddy/skills、~/.config/opencode/skills(含 .trash 回收站)、
当前目录 .workbuddy/skills;Windows 上另查 WSL 端对应两根(folder 是
WSL UNC 直接查,否则 VM 在跑才查)。
全程只读、任何一步失败跳过;返回找到的 SKILL.md 路径列表(去重,
已排除「诊断对象自己」)。
"""
name = Path(folder).name
if not name:
return []
home = Path.home()
roots: list[Path] = [home / ".workbuddy" / "skills",
home / ".config" / "opencode" / "skills"]
extra = os.environ.get("OC_SKILL_ALT_ROOTS", "")
roots += [Path(p) for p in extra.split(os.pathsep) if p.strip()]
try:
roots.append(Path.cwd() / ".workbuddy" / "skills")
except OSError:
pass
if os.name == "nt":
parts = _unc_distro_rest(Path(folder))
if parts:
wsl_on = True # folder 本身就在 WSL 里,同端根一定可查
distro = parts[0]
else:
distro = DEFAULT_DISTRO
try:
wsl_on = _wsl_running(distro)
except Exception:
wsl_on = False
if wsl_on:
unc = Path("\\\\wsl.localhost\\%s" % distro)
roots += [unc / "root" / ".workbuddy" / "skills",
unc / "root" / ".config" / "opencode" / "skills"]
def _norm(p) -> str:
return os.path.normcase(os.path.abspath(str(p)))
self_dir = _norm(folder)
out: list[str] = []
seen: set[str] = set()
for r in roots:
# <根>/<同名> + 回收站 <根>/.trash/<时间戳>/<同名>
cands = [r / name]
try:
if (r / ".trash").is_dir():
cands += [t / name for t in sorted((r / ".trash").iterdir())
if t.is_dir()]
except OSError:
pass
for cand in cands:
if _norm(cand) == self_dir:
continue # 就是诊断对象自己(已知空壳,别自己找自己)
try:
md = find_skill_md(cand)
except OSError:
md = None
if md:
key = _norm(md)
if key not in seen:
seen.add(key)
out.append(str(md))
return out
def diag_skill_md(folder) -> list[str]: def diag_skill_md(folder) -> list[str]:
"""SKILL.md 缺失时的软链接诊断(只读,2026-09-24 按用户反馈新增)。 """SKILL.md 缺失时的软链接诊断(只读,2026-09-24 按用户反馈新增)。
@@ -884,24 +957,42 @@ def diag_skill_md(folder) -> list[str]:
timeout=20) timeout=20)
except Exception: except Exception:
code, out = 1, b"" code, out = 1, b""
if code == 0 and out: if code == 0:
hits = 0
for ln in out.decode("utf-8", "replace").splitlines(): for ln in out.decode("utf-8", "replace").splitlines():
ln = ln.strip() ln = ln.strip()
if ln.startswith("MD "): if ln.startswith("MD "):
wsl_md = ln[3:].strip() wsl_md = ln[3:].strip()
found_md.append(wsl_md) found_md.append(wsl_md)
hits += 1
notes.append(f" ✓ WSL 内找到 SKILL.md:{wsl_md}") notes.append(f" ✓ WSL 内找到 SKILL.md:{wsl_md}")
elif ln.startswith("LINK "): elif ln.startswith("LINK "):
hits += 1
notes.append(f"WSL 内链接:{ln[5:].strip()}") notes.append(f"WSL 内链接:{ln[5:].strip()}")
if not hits:
# 空结果也要说话(2026-09-25):静默会让人误以为二层没跑
notes.append(" ℹ WSL 内二层已查:目录里没有 SKILL.md、也没有链接"
"(WSL 内视角与 Windows 一致)")
else:
notes.append(f" ℹ WSL 内二层查询未完成(rc={code}),只按 Windows 视角给结论")
else: else:
# 不静默跳过(2026-09-24 单测暴露):说明二层没查,让用户知道还能再查 # 不静默跳过(2026-09-24 单测暴露):说明二层没查,让用户知道还能再查
notes.append(" ℹ WSL 当前未运行,跳过 WSL 内二层诊断" notes.append(" ℹ WSL 当前未运行,跳过 WSL 内二层诊断"
"(唤醒 WSL 后重试,可查出链接真身;VM 冷启动约 5–20 秒,不自动唤醒)") "(唤醒 WSL 后重试,可查出链接真身;VM 冷启动约 5–20 秒,不自动唤醒)")
# 结论 # 结论:先跨已知根找同名真身(空壳目录的常见真相 —— 真身在 wb 等另一个根,
# 大模型调用的正是那个文件),找不到才回退「仍未找到」
if not found_md: if not found_md:
notes.append("→ 仍未找到 SKILL.md 的真实位置:目录可能是空壳 / 链接目标里也没有文件," alts = _alt_skill_md(folder)
"建议换一个完整的 skill 来源再推。") if alts:
notes.append("→ 本目录里没有 SKILL.md(空壳/断链),但同名 skill 的真身在别处:")
for a in alts:
notes.append(f" {a}")
notes.append(" 大模型实际调用的就是上面这些文件;推送请改选包含真身的目录"
"(如 wb 终端)作为来源。")
else:
notes.append("→ 仍未找到 SKILL.md 的真实位置:目录可能是空壳 / 链接目标里也没有文件,"
"建议换一个完整的 skill 来源再推。")
return notes return notes