Buddy 应用(专家包)技术内幕

这不是一份「点这里、填那个」的操作手册。它要回答的是:一个 Buddy 应用(专家 / 专家团)在磁盘上是什么、在进程里变成什么、以及中间那段代码在干什么。 读完你应当能独立于任何 UI,用文本编辑器从零造出一个能在 WorkBuddy 里真正跑起来的专家。

本文所有结论均来自对本机 WorkBuddy 安装包的实证取证 —— 包括逆向主进程 app.asar、命令行内核 codebuddy-lite-wb.mjs、创作脚本 expert-manager/scripts/*.py,以及对 16 个真实落盘专家包的统计。 凡与官方文档口径不一致处,均以代码证据为准,并在文中明确标出。

Section 00

0 阅读前提与取证方法

在往下读之前,先交代本文的三条取证路径,方便你自己复核:

静态规范
resources/plugins/workbuddy-builtin/skills/expert-manager/
含 SKILL.md + 4 份 references + 7 个 Python 脚本。这是「应该长什么样」的权威来源。
运行时代码
主进程在 app.asar(96 MB,未解包,需按字节偏移取证); 插件 / 智能体内核在 cli/dist/codebuddy-lite-wb.mjs(9.5 MB)。这是「实际怎么跑」的来源。
落盘样本
~/.workbuddy/plugins/marketplaces/experts/plugins/ 下 16 个真实专家包, 共 45 个 Agent MD。这是「别人怎么写的」的来源。
为什么需要逆向

WorkBuddy 桌面端是 Electron 应用,业务代码经过打包压缩。app.asar 未解包且单行可达数十万字符, 无法按行号引用,只能按字节偏移定位。文中涉及主进程的代码片段均标注偏移值, CLI 侧则可按行号(文件共 2678 行)+ 字符位置引用。

本文的取证纪律

凡文中出现的机制描述,均可追溯到具体文件与片段。查不到证据的,明确标注「未找到证据」,不做脑补。 第 11 节专门罗列了官方规范与代码实现相矛盾的 12 处,这些是踩坑高发区。

Section 01

1 它到底是什么

1.1 四个容易混淆的概念

WorkBuddy 里有四套可插拔机制,它们共享「Markdown 定义 + 目录扫描」这一思路,但运行时待遇完全不同。 分不清这四者,是绝大多数困惑的源头。

概念物理位置运行时载体装载时机能否被 spawn
Skill
技能
~/.workbuddy/skills/*/
或插件内 skills/
指令文本注入上下文
(含 scripts/ 可执行脚本)
按需 命中描述才加载 否 是工具箱不是角色
Custom Agent
用户级智能体
~/.workbuddy/agents/*.md
或项目 .workbuddy/agents/
独立 agent 配置 启动时 全部注册 是
Expert
专家 / Buddy
插件包 <expert>/agents/*.md 插件组件 → 装到默认主 agent 上 选中时 动态切换 是(作为主会话人格)
Team
专家团
expertType: "team" 的专家包 主理人 agent + N 个团员 agent 选中时 另开 Agent Teams 开关 是(成员被逐个 spawn)
关键区分:专家 ≠ 用户级 Agent

两者都能产出「可 spawn 的智能体」,但机制是两套独立实现:

  • 用户级 Agent 走 CustomAgentsProductProvider,直接扫 <home>/agents 目录,不读 plugin.json、不带 PLUGIN_TAG。
  • 专家包走 PluginsProductProvider → AgentExtensionLoader,必须依附于一个插件包,带 PLUGIN_TAG,且受 marketplace 生命周期管理(可下载、可更新、可切换)。

所以「专家」的附加价值不在 MD 本身,而在它是一等公民插件:有版本、有市场、能分发、能打命名空间。

1.2 一句话总纲

核心命题

专家包 = 一个标准的 WorkBuddy 插件,只是额外带了一个 expertType 标记,并且把其中一个 Agent MD 提升为会话的默认人格。

理解这一句,后面所有机制都是它的推论:为什么要注册进 marketplace(因为是插件)、 为什么 bin/ 会进 PATH(因为是插件)、为什么技能有命名空间前缀(因为是插件)、 为什么 team 需要开关(因为是插件之上的额外标记)。

把这句话展开成三层管线,就是本文的骨架:

PHASE 1创作期生成目录骨架、填内容、做头像
→
PHASE 2注册期写 marketplace.json 条目
→
PHASE 3分发期打包 zip / 上传市场 / 下载落盘
→
PHASE 4激活期POST /plugins/switch
→
PHASE 5装载期MD → 智能体注册
一个反直觉的事实

对于本地自建的专家,PHASE 3(分发)是完全跳过的 —— 你的目录就在 my-experts/plugins/ 里躺着,没有下载、没有解压、没有 .downloaded_at。 直接从 PHASE 2 跳到 PHASE 4。这也是为什么本地专家改完立刻生效。

Section 02

2 包体解剖:磁盘上长什么样

2.1 目录树

my-code-reviewer/ ← 目录名 = kebab-case,是包的身份之一 ├── .codebuddy-plugin/ │ └── plugin.json 【必需】唯一入口清单 │ ├── agents/ 【必需】智能体定义(.md) │ ├── code-reviewer.md ← Agent 型:仅此一个 │ ├── review-team-lead.md ← Team 型:主理人(必须带团队前缀) │ ├── security-reviewer.md ← Team 型:团员 │ └── perf-reviewer.md │ ├── avatars/ 【可选】头像,PNG 512×512 │ ├── expert.png │ └── security-reviewer.png │ ├── skills/ 【可选】随包技能 │ └── ast-scan/ │ └── SKILL.md │ ├── bin/ 【可选】可执行文件,自动入 PATH │ └── crmetric │ ├── settings.json 【Team 型创作期必需,运行时会被移除】 ├── README.md 【推荐】 └── .created-by-session ← 注册脚本自动写入的会话标记
目录位置是硬约束,不是建议

自建专家必须放在 $WORKBUDDY_CONFIG_DIR/plugins/marketplaces/my-experts/plugins/<name>/ (默认即 ~/.workbuddy/plugins/marketplaces/my-experts/plugins/)。 放在别处,扫描器根本不会去看它。init_expert.py 里对此做了硬检查,传错 --path 直接退出:

expected_path = get_expert_plugins_dir()
resolved_path = Path(output_path).expanduser().resolve()
if resolved_path != expected_path.resolve():
    print(f"❌ Error: --path 必须为专家目录: {expected_path}")
    print(f"   非专家目录下创建的专家无法被检测到。")
    sys.exit(1)

2.2 plugin.json 字段全解

这是整个包的中枢。下面按运行时实际如何使用来分类,而非按文档顺序 —— 标注为「展示态」的字段不参与运行时行为。

身份与类型(运行时强依赖)

字段必填作用与运行时待遇
name必需 包唯一标识,kebab-case 正则 ^[a-z0-9][a-z0-9-]*[a-z0-9]$,至少 2 字符。用于拼 "<name>@<marketplace>" 这个插件 ID。
version必需 语义化版本。官方包用它比对本地落盘版本与 marketplace 条目版本,不一致则重新 materialize。
description必需 英文一句话。它是 marketplace.json 条目的 description,也是卡片兜底文案。
expertType必需 agent 或 team。这是唯一的语义开关:决定卡片显示「专家」还是「专家团」,决定主进程是否注入 CODEBUDDY_CODE_EXPERIMENTAL_AGENT_TEAMS=1 与 X-Expert-Team-Task 头。
agentName必需 主 Agent 名,必须等于 agents/ 下某个 MD 的文件名(不含 .md)。激活时它随 /plugins/switch 请求送给 CLI。禁止用通用名 team-lead。
teamInfoteam 必需 {leadAgent, memberAgents[]}。运行时只认 leadAgent;memberAgents 在整个 app.asar 中出现 0 次,是纯文档/市场字段(详见 §11 第 4 条)。

资源声明(决定加载什么)

字段必填作用与运行时待遇
agents可选 路径数组,如 ["./agents/x.md"]。可以不写 —— 加载器读完此字段后还会兜底扫描 <plugin>/agents/ 整个目录。实证:官方 software-company 包就没有这个字段,照样加载 5 个成员。
skills可选 技能目录数组。同理,加载器也会兜底扫 <plugin>/skills/。
bin死字段 规范里有,扫描器会归一化,但 CLI 侧无任何消费者。bin/ 目录本身是约定自动探测的,不需要也不受此字段影响。

展示态字段(不参与运行时行为)

字段约束说明
displayName必需{en, zh}。卡片标题(花名)。Team 型必须与 profession 完全一致,校验器直接判 error。
profession必需{en, zh}。职业头衔。
displayDescription40-50 字{en, zh}。中文超出 40–50 字区间校验器只给 warning,不阻断。
categoryId必需12 个分类之一;但实际还存在第 13 个 13-TencentZone(见 §14)。
avatar可选相对路径。会被解析成 file:// URL,超过 5 MB 丢弃。注意:官方落盘包普遍不含 avatars 目录(16 个包中 5 个无头像),说明市场包走远程 URL 分发。
defaultInitPrompt必需{en, zh}。必须与 quickPrompts[0] 一致(不一致只 warning)。
tags恰好 3擅长领域标签。多于或少于 3 个 = error。
quickPrompts恰好 3推荐提示词。渲染层会用 quickPrompts[0] 覆盖 defaultInitPrompt,所以两者写不一样时,界面上显示的是前者。
plugin必需必须等于 name。不一致 = error。
members[]team 必需每个含 {id, name, profession, avatar, role},role 取 lead/member,主理人也必须在内且 role: "lead"。仅用于渲染头像墙,运行时不消费。

2.3 Agent MD 的格式

每个 agents/*.md 文件就是一个智能体定义。结构极简:YAML frontmatter 管元数据,正文就是 system prompt。

agents/code-reviewer.md.md
---
name: code-reviewer                    # 【必需】必须等于文件名(不含 .md)
description: Reviews code for bugs, security and performance issues.
displayName:
  en: "Code Review Expert"
  zh: "代码审查专家"
profession:
  en: "Senior Code Reviewer"
  zh: "资深代码审查师"
maxTurns: 50                           # 可选,正整数
skills: [ast-scan]                     # 可选,预加载的技能
---

# 代码审查专家 - 审稿人

你是一名资深代码审查师……(这段就是 system prompt)

## 核心能力
1. **缺陷识别**:……
2. **安全审计**:……

## 工作流程
1. 先通读 diff 全貌
2. 逐文件审查
3. 汇总输出

## 输出规范
- 按严重程度分级
- 每条给出行号与修改建议

## 注意事项
- 不做主观风格争论

frontmatter 字段的真实待遇

字段是否生效运行时处理
name生效成为 subagent_type 的名字。缺失时回退用文件名(去掉 .md)。
description生效缺失时回退用 name。加载器还会自动追加来源标签,例如 "... (my-experts:agents)",用于在模型面前区分同名 agent。
instructions(正文)生效核心:整段正文就是 system prompt。且会做变量替换(见下)。
tools禁用规范明令禁止在 frontmatter 声明。运行时虽会解析,但工具权限由系统统一分配。
maxTurns生效必须正整数,否则丢弃。主理人建议 150–200(因为要编排多轮),普通成员 50。
skills生效预加载技能,解析时会尝试加插件名前缀(见 §8)。
effort生效推理强度,经 normalizeReasoningEffort 归一化。
background生效布尔值,是否后台运行。
isolation生效只有 "worktree" 值被接受,其它一律丢弃。
initialPrompt生效智能体启动后的首条输入。
memory生效记忆配置。
color生效缺失时用 generateColorFromName(name) 按名字生成。
permissionMode忽略这三个字段即使写了也被静默忽略并打 warning:
[AgentExtensionLoader] plugin agent 'x' sets permissionMode, which is ignored for plugin agents.
这是有意设计 —— 插件不应越权改宿主权限模型。
hooks忽略
mcpServers忽略
变量替换:${CODEBUDDY_PLUGIN_ROOT}

正文里的 ${CODEBUDDY_PLUGIN_ROOT}(以及兼容别名 ${CLAUDE_PLUGIN_ROOT})会被替换成插件根目录的绝对路径。 这是让 MD 能引用包内资源的关键 —— 比如 请读取 ${CODEBUDDY_PLUGIN_ROOT}/references/rules.md。 没有它,智能体无法定位自己的包在哪。

2.4 Team 型的额外约定

文件命名
主理人文件必须带团队前缀:{team}-team-lead.md。 用通用的 team-lead.md 会被校验器判 error —— 因为同名会跨包冲突。
members 包含主理人
members[] 含主理人(role: "lead"),但 teamInfo.memberAgents 不含主理人。校验器会检查「members 里至少有一个 lead」以及「memberAgents 不含 lead」。
settings.json
创作期必需,内容仅 {"agent": "{team}-team-lead"}。 但官方落盘包里一个都不存在(见 §11 第 5 条),它本质是过渡产物。
SOP 写在 MD 里
阶段编排、协作铁律、红线全部写在主理人 MD 正文中,没有任何运行时强制。这就是「代码 = SOP(团队)」的含义。
Section 03

3 创建流水线:七个脚本

创作期由 expert-manager 这个技能驱动,共 5 个 Python 脚本(外加 2 个测试脚本)串成流水线。 它们全部是纯 stdlib 实现,无第三方依赖。

技能目录结构expert-manager/
resources/plugins/workbuddy-builtin/skills/expert-manager/
├── SKILL.md                     179 行   # 流程与铁律
├── references/
│   ├── plugin-json-spec.md      184 行   # plugin.json 字段规范 + 两套模板
│   ├── agent-md-spec.md         179 行   # Agent MD 结构(普通 / 主理人)
│   ├── team-spec.md             120 行   # 协作铁律、命名、SOP 编排
│   └── avatar-spec.md           162 行   # 头像 prompt 构建策略
└── scripts/
    ├── init_expert.py           485 行   # ① 生成骨架
    ├── validate_expert.py       381 行   # ② 校验
    ├── register_expert.py       271 行   # ③ 注册
    ├── package_expert.py        121 行   # ④ 打包
    ├── batch_create.py          141 行   # 批量编排(模板)
    ├── test_register_expert.py   78 行
    └── test_package_expert.py    95 行
  1. init_expert.py — 生成骨架 按 --type agent|team 生成目录 + 带 [TODO] 占位符的 plugin.json 与 MD 模板。 Agent 型产出 1 个 MD;Team 型产出 {team}-team-lead.md + 一个 member-placeholder.md(需自行改名)+ settings.json。 同时校验 --path 必须是专家目录、name 必须是 kebab-case。
  2. 填充内容 把 [TODO] 换成真实内容。这一步由 AI 或人完成,是唯一有创造力含量的环节。 正文质量直接决定专家质量 —— 因为正文就是 system prompt,没有中间层。
  3. ImageGen — 生成头像 按 avatar-spec.md 从 MD 内容反推 prompt:读角色定义 → 提核心能力关键词 → 转视觉元素 → 拼装。 Agent 型 1 张;Team 型 N+1 张(团队整体 + 主理人 + 每个团员)。同团队共用风格锚定词与背景色调。
  4. validate_expert.py — 校验 会做五大类检查:位置(必须在 my-experts 目录下)、结构(禁止 hooks/、commands/、.lsp.json,禁止资源目录藏在 .codebuddy-plugin/ 内)、 字段(必填项、kebab-case、tags/quickPrompts 恰好 3 个、team 的 profession ≡ displayName)、 一致性(agentName 有对应 MD、frontmatter name ≡ 文件名)、 禁用项(frontmatter 不得有 tools)。
  5. register_expert.py — 注册 二次校验(重点查 [TODO] 残留)后,向 marketplace.json 写入条目 {"name": ..., "source": "./plugins/<dir>", "description": ...}, 并向专家目录写入 .created-by-session 标记文件(值取自环境变量 CODEBUDDY_SESSION_ID)。
  6. package_expert.py — 打包 先跑一遍完整校验,通过后打 zip。会自动跳过隐藏文件(.codebuddy-plugin 除外)、 __pycache__、node_modules、.gitkeep、.DS_Store。
  7. 批量(可选) batch_create.py 提供模板:对每个专家串行执行 init → 填充 → validate → register, 任一步失败即停止。规范明令禁止「只写文件跳过校验注册」和「直接改 marketplace.json」。
铁律:不注册 = 不存在

第 ⑤ 步不是可选项。扫描器以 marketplace.json 为唯一入口,文件缺失直接返回空列表; 即使目录和 plugin.json 都完好,只要 marketplace.json 里没条目,专家在界面上就不存在。 跳过这一步直接手搓文件,是最常见的「我明明写了为什么看不见」的原因。

3.1 注册脚本的关键片段

register_expert.pyPython
def register_expert(expert_dir, marketplace_dir, session_id=None):
    manifest_path = marketplace_dir / '.codebuddy-plugin' / 'marketplace.json'
    ...
    source = f"./plugins/{expert_dir.name}"

    # 已存在则更新,否则追加 —— 保证幂等
    for p in plugins:
        if p.get('source') == source or p.get('name') == expert_name:
            p['name'] = expert_name
            p['source'] = source
            p['description'] = expert_description
            ...
            return True

    plugins.append({"name": expert_name,
                    "source": source,
                    "description": expert_description})

    if session_id:
        _write_session_marker(expert_dir, session_id)   # 写 .created-by-session
.created-by-session 是标记文件,不是 JSON 字段

它是专家目录下的一个独立文件,内容就是会话 ID 字符串。 用途是「预览态」状态机:主进程在会话结束后会把它 rename 成 .pending-preview-session 或直接删除。 它不参与「我的专家 vs 市场专家」的判定 —— 那个判定靠 marketplace === 'my-experts'。

Section 04

4 分发与版本:市场包如何落到磁盘

本地自建专家跳过本节。但如果你要分发,或者想理解官方专家的行为,这一节解释它们从哪来。

4.1 两套落盘位置

官方市场包
~/.workbuddy/plugins/marketplaces/experts/plugins/<bundleName>/
扁平结构,无 version 层级
本地自建
~/.workbuddy/plugins/marketplaces/my-experts/plugins/<dirName>/
市场元数据
~/.workbuddy/app/cache/experts/manifest.json(1.2 MB 目录清单)
ID 映射表
~/.workbuddy/app/cache/experts/expert-bundle-map.json
一个常见误解

网上(以及早期版本)流传的缓存路径是 plugins/cache/experts/<name>/<version>/。 这个目录确实存在(本机有 4 个包),但它不是运行时主路径。 真正被扫描和使用的是 marketplaces/experts/plugins/<bundleName>/ —— 无版本层级, 一个包一份,更新时原地覆盖。想找当前生效的官方包,去后者。

4.2 下载与新鲜度判定

app.asar偏移 ~84156000 · ExpertBundleRuntime
async ensureDownloaded(e, t, n){
  let r = this.getPluginDir(e);
  if (await this.deps.hasPluginManifestAsync(r)){
    if (t && await this.isLocalStale(r, t))
      console.log(`...Local cache stale, re-downloading: ${e}`);
    else return await this.logBundleSnapshot(`cache`, e, r), r
  }
  let o = this.downloadAndExtract(e, r, n);
  ...
}

async isLocalStale(e, t){
  let n = x.join(e, `.downloaded_at`),
      r = await O.readFile(n, `utf-8`),
      i = new Date(r.trim()).getTime();
  return new Date(t).getTime() > i        // 服务端 updatedAt > 本地时间戳 → 过期
}

流程要点:

bundle 名 ≠ 目录名

expert-bundle-map.json 维护市场 ID 到目录名的映射,且可以多对一:

{
  "ex_XoPVlmyOgAY9": "fullstack-dev",
  "SoftwareCompany": "software-company",
  "ex_jPdPfIC04OKD": "software-company",      // ← 两个市场 ID 指向同一目录
  "ex_NsJv0iGhmXQh": "up-stock-strategy-analyze"
}

说明市场侧存在 ID 迁移/别名机制,落盘后统一收敛到目录名。

Section 05

5 激活:点击专家的那一瞬间发生了什么

这是全链路最精妙的一环。很多人以为「选中专家」只是记了个 UI 状态,实际上它触发了一次 跨进程的配置写入 + HTTP 调用。整个过程分四步。

5.1 第一步:主进程准备事实

点击后,主进程的 createConversationExpertRuntimePreparer 开始工作(app.asar 偏移 ~76308100):

app.asar偏移 ~76308100 · 读取清单
async function readPluginManifestFromDir(e){
  for (let t of Je){                    // Je = [".codebuddy-plugin", ".workbuddy-plugin", ...]
    let n = T.join(e, t, Ye);           // Ye = "plugin.json"
    try{
      let e = await E.readFile(n, `utf8`), t = JSON.parse(e);
      return {name:..., id:..., agentName:...,
              expertType: optionalExpertType(t.expertType),   // ← 类型在此被读取
              marketExpertId:..., sourceId:..., displayName:t.displayName}
    }catch(e){ if(e.code === `ENOENT`) continue; return }
  }
}
注意:这里只抽取 7 个字段

主进程从这里只拿 name / id / agentName / expertType / marketExpertId / sourceId / displayName, categoryId、tags、quickPrompts、members 都不进这条记录。 它们只在面向 UI 的完整 DTO 构造(readCustomExpertFromDir)里才被读取。 这是两条独立的数据流:精简流走运行时,完整流走渲染。

随后是落盘状态机,它定义了包的三态:

app.asar偏移 ~76308100 · inspectLandedBundle
if (!a || !o) return { status: `incomplete-names` };   // 缺 name 或 agentName

return await hasMarketplaceBundleEntry(e, n)
  ? { status: `complete`,      candidate: s }          // 目录在 + 已注册
  : { status: `needs-register`, candidate: s };        // 目录在 + 未注册

同时,针对 team 型,主进程会先解析出主理人 MD 的路径用于提示词预览:

app.asar偏移 ~84206000 · 定位主理人 MD
// resolveExpertAgentPromptSource
let o = a.expertType === `team`
      ? a.teamInfo?.leadAgent                          // team → 直接用 leadAgent
      : await this.resolveExpertAgentName(i, a, e);    // agent → 回退链

let s = await resolveExpertAgentMarkdownPath(i, o);
return s ? {agentName: o, filePath: s} : void 0

// resolveExpertAgentName 的回退链
async resolveExpertAgentName(e, t, n){
  if (t.agentName) return t.agentName;                                   // ① plugin.json
  if (t.expertType === `team` && t.teamInfo?.leadAgent) return t.teamInfo.leadAgent;  // ②
  let r = this.toKebabCase(n); return await this.resolveAgentNameFromPluginFiles(e, t, r)  // ③ 猜
}
这条路径只用于 UI 提醒,不是注入通道

注意区分:主进程读 MD 只是为了在界面上给用户看「这个专家的开场白/人设」 (resolvePreparedExpertReminder)。 真正把 MD 内容装进模型的是后面 CLI 那条链路。两条链路各自独立读盘,互不共享。

5.2 第二步:写入会话配置

app.asar偏移 ~76323000 · applyPreparedExpertSettings
function applyPreparedExpertSettings(e, t, n){
  let r = e ?? {},
      i = asRecord(r.enabledPlugins),
      a = asRecord(r.extraKnownMarketplaces),
      o = asRecord(r.env),
      s = ...(o.CODEBUDDY_CUSTOM_HEADERS),
      c = [`X-Expert-Id: ${t.canonical.id}`,
           ...t.expertType === `team` ? [`X-Expert-Team-Task: true`] : []],
      l = t.canonical.marketplace;

  return {...r,
    enabledPlugins: d.w(i, `${t.pluginName}@${l}`),          // 启用 @
    extraKnownMarketplaces: {...a, [l]: {source: {source: `directory`,
                              url: n.pluginService.getExpertMarketplacePath(l)}}},
    env: {...o, CODEBUDDY_CUSTOM_HEADERS: [s, ...c].filter(Boolean).join(`\n`)}}
}

三件事:

5.3 第三步:HTTP 切换(真正的激活)

app.asar偏移 ~84198000 · SessionPluginSwitcher
let _ = `${s}/api/v1/plugins/switch`, v = {persist: !1};

g.length > 0 && (v.disable = g[0]);          // 关掉前一个
n && (v.enable = `${n}@${d}`);               // 启用这个包
r && (v.agentName = r);                      // ← 主理人 / 主 agent 名
let y = n ? `${n}@${d}` : ...;
y && (v.sourcePluginId = y);
a !== void 0 && (v.internalModelRequestHeaders = a);

await fetch(_, {method: `POST`, body: JSON.stringify(v)})

CLI 侧对应两个入口:

cli/dist/codebuddy-lite-wb.mjsline 964 · char ~3183020
async setVolatilePluginAgent(t, i = {}){
  let n = this.normalizePluginAgentSetting(t), ...
  this.volatilePluginAgentName = n;
  this.volatilePluginSourcePluginId = s;
  this.volatilePluginInternalModelRequestHeaders = c;
  ...
}
为什么叫 volatile(易失)

请求体里 persist: false 是关键:这次切换只作用于当前会话,不落盘。 这解释了一个常见现象:你在 A 会话选了专家,开个新会话又是默认助手。 专家是会话级的,不像技能那样全局常驻。

5.4 第四步:settings.json 的真相

规范说 team 型必须有 settings.json(内容 {"agent": "xxx-team-lead"})。CLI 确实读它,但待遇微妙:

cli/dist/codebuddy-lite-wb.mjsline 2511 · char ~8030503
let L = t.settings;
try{
  let t = ed.join(i, "settings.json");
  await ef.Sn.access(t);
  let n = JSON.parse(await ef.Sn.readFile(t, "utf-8")),
      s = {...n.agent ? {agent: n.agent} : {},
           ...void 0 !== n.subagentStatusLine
              ? {subagentStatusLine: n.subagentStatusLine} : {}};   // ← 只认这 2 个 key
  Object.keys(s).length > 0 && (L = {...L, ...s})
}catch{}
return {..., settings: L}
而且主进程会主动删掉它

主进程启动时全盘扫描 plugins/*/settings.json,把这种「只有一个 agent key 的生成物」 视为遗留产物并删除(readGeneratedAgentOverride + removePersistedGeneratedAgentOverrides, app.asar 偏移 ~84212259)。

这也解释了本机实测:16 个官方落盘包里,settings.json 一个都不存在。 结论:settings.json 是创作期的合规要求,运行时的过渡产物。 真正决定激活的是 volatile agentName(走 HTTP 切换)。 你写它是为了过校验,不必指望它生效。

Section 06

6 核心:Markdown 如何变成活的智能体

这是整个机制的心脏。前面所有流程都是为这一刻铺路:把 agents/*.md 这个纯文本文件, 变成模型可以 Agent({subagent_type: "xxx"}) 调度的真实智能体。

6.1 加载器:声明 + 兜底双通道

cli/dist/codebuddy-lite-wb.mjsline 2511 · char ~8029341 · loadPluginComponents
async loadPluginComponents(t, i){
  h.push(this.agentLoader.load(i, n, this.normalizePathConfig(t.agents), p) ...)
  h.push(this.skillLoader.load(i, n, this.normalizePathConfig(t.skills), p) ...)
  ...
}

// normalizePathConfig:string → [string];Array → filter(string);否则 undefined

进入 AgentExtensionLoader 后才是重点:

cli/dist/codebuddy-lite-wb.mjsline 2511 · char ~7992642 · AgentExtensionLoader.load
async load(t, i, n, s){                    // t=插件根目录, i="name@marketplace", n=agents路径列表
  if (n && n.length > 0)
    c.push(...await this.loadFromPaths(t, i, n, p, h));    // ① 按 plugin.json 声明加载

  let s = ed.join(t, tK);                                   // tK = "agents"
  if (await this.pathExists(s) && !p.has(s))
    c.push(...await this.scanAgentsDirectory(s, i, p, t, h));  // ② 兜底扫整个目录
}
实证:官方包真的在依赖这个兜底

本机 16 个官方包中,software-company 的 plugin.json 完全没有 agents 字段, 但它的 agents/ 目录里有 5 个 MD,运行时照常加载全部成员。

对工程师的意义:你可以不写 agents 声明,只管往 agents/ 里扔 MD。 但反过来,声明了却不存在的路径会报 error。所以最省心的做法是——声明与目录保持一致, 或者干脆只放目录。

6.2 解析:frontmatter → 配置对象

cli/dist/codebuddy-lite-wb.mjsline 2511 · char ~7995000 · parseAgentFile(精简)
let n = await eu.MarkdownUtils.extractFrontMatterFull(i);      // 解析 YAML frontmatter
n.parseError && this.logger.warn(`[AgentLoader] Malformed YAML frontmatter ...`);

let E = n.data,
    R = ed.basename(A).replace(/\.md$/, ""),
    P = E.name || R,                          // name ← frontmatter,缺失回退文件名
    I = this.generateSourceLabel(A, S),
    O = E.description || P;
O.includes(I) || (O = `${O} ${I}`);           // description 自动追加来源标签

let M = eu.MarkdownUtils.parseListField(E.tools),
    L = ...parseListField(E.disallowedTools),
    $ = ...parseListField(E.skills),
    F = normalizeReasoningEffort(E.effort),
    U = "worktree" === E.isolation ? E.isolation : void 0,
    B = eu.MarkdownUtils.parsePositiveIntField(E.maxTurns),   // 非正整数 → 丢弃
    W = eu.MarkdownUtils.parseBooleanField(E.background),
    q = E.initialPrompt, G = tG.Ic(E.memory), z = E.model?.trim();

for (let t of ["permissionMode", "hooks", "mcpServers"]){     // 这三个被忽略
  ...this.logger.warn(`[AgentExtensionLoader] plugin agent '${P}' sets ${t},
      which is ignored for plugin agents.`)
}

let J = n.content.trim(),                     // ← MD 正文
    Y = c ? await tV.C(J, p, V, c) : tV.r(J, p, V);   // ← 变量替换

return {
  name: P, description: O, instructions: Y,   // ★ 正文 → instructions(system prompt)
  ...N && {tools: N}, ...L.length && {disallowedTools: L},
  models: z ? [z] : [],
  asTool: !0,                                // ★★ 关键开关
  color: E.color || generateColorFromName(P),
  tags: [ej.UxB, ej.HOt, tz.PLUGIN_TAG, S],  // ★ S = "pluginName@marketplace"
  ...F && {effort: F}, ...void 0 !== B && {maxTurns: B}, ...W && {background: W},
  ...q && {initialPrompt: q}, ...$.length && {skills: $},
  ...G && {memory: G}, ...U && {isolation: U}
}

变量替换紧跟其后:

cli/dist/codebuddy-lite-wb.mjsline 2337 · char ~6775537
E = E.replace(/\$\{CODEBUDDY_PLUGIN_ROOT\}/g, t)
     .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, t)

6.3 三个关键产物

instructions
MD 正文 = system prompt。没有包装、没有模板、没有中间层。 你写的每一个字都会原样进入模型上下文。

这既是它的力量(完全可控),也是它的陷阱(废话会消耗 context 且稀释指令)。
asTool: true
硬编码为 true,不可配置。这是该 agent 能否出现在 Agent 工具的可派生清单里的唯一开关。
tags 末位
tags 数组第 4 项(末位)是 "<pluginName>@<marketplace>"。 它是技能命名空间解析的依据(见 §8),也是运行时识别来源的标记。

6.4 注册到 Agent 工具:可 spawn 的秘密

配置对象造出来后,还要过三关才能真正被模型调用。

①并入 product 组件
line 984 · char ~3402304 · PluginsProductProvider.provide
let t = await this.pluginManager.getActivePlugins(); ...
for (let {components: i} of t){
  let {settings: t, ...n} = i;                      // ← settings 被剥离,其余并入
  s = await this.productMerger.merge(s, {...n, mergeStrategy: SmartMerge})
}
②注册进 agentConfigMap
line 970 · char ~3232128 · AgentManager.registerPluginAgents
let c = s?.components.agents ?? [];
for (let t of c){
  let s = t?.name;
  if (s && !this.agentConfigMap.has(s)){     // ← 先到先得,重名不覆盖
    this.agentConfigMap.set(s, n);
    i.push(n)
  }
}
③写进 Agent 工具描述
line 1852 · char ~5581576 · 生成工具描述
A = [
  "Launch a detached worker...",
  "",
  "Available agent types:",
  ...q.filter(t => !0 === t.asTool || t.name === nI.QYL.GENERAL_PURPOSE)
     .map(t => `- ${t.name}${i ? `: ${i}` : ""}${n}`),
  ...
].join("\n")
字符预算

清单超长时会被 truncateAgentsByCharBudget(line 1854)截断。 这解释了为什么插件开太多时,某些 agent 会从清单里消失 —— 不是没加载,是被预算裁掉了。

至此,模型看到的 Agent 工具描述里就有了 - security-reviewer: 安全审计专家 这一行, 于是它能发出 Agent({subagent_type: "security-reviewer", name: "security-reviewer", prompt: "..."})。

这就是为什么规范强调 name 和 subagent_type 必须传 Agent ID

Team 型规范里反复强调「调度成员时 name 与 subagent_type 都要传 MD 文件名」, 根因就在这里:Agent 工具的清单是按 name 字段索引的, 传中文名或自创名会匹配不到,系统只能退化处理(可能生成无意义名称或退化成 general-purpose)。

Section 07

7 Team 的运行时保障:只有两个,都是硬编码

如果你以为专家团有什么精巧的编排引擎,会失望。运行时的全部贡献只有两点。

① 打开 Agent Teams 开关
app.asar 偏移 ~75243300
let l = `1`, u = `0`, d = `team`;
function resolveAgentTeamsEnv(e = {}){
  return e.expertType === d ? l : e.disableAgentTeams ?? !0 ? u : l
}
var f = `CODEBUDDY_CODE_EXPERIMENTAL_AGENT_TEAMS`;

CLI 侧判定:isEnabled(){ return "0" !== process.env.CODEBUDDY_CODE_EXPERIMENTAL_AGENT_TEAMS }
即 expertType === "team" → 环境变量置 1 → TeamCreate / Agent / SendMessage 三个工具可用。

② 打一个 HTTP 头

X-Expert-Team-Task: true,随请求发往后端。

用途是让服务端知道当前会话是团队任务(可能用于计量、路由或后端侧的差异化处理), 不影响本地编排行为。

仅此而已 —— SOP 没有任何运行时强制

代码证据:

  • memberAgents 在整个 app.asar(96 MB)中出现 0 次。唯一被读的是 teamInfo.leadAgent。
  • expertType 在 CLI(9.5 MB)中出现 0 次。CLI 只认标准插件组件。
  • members[] 只被归一化(id 从 agent 字段回填、avatar 转 URL)后透传给渲染层做头像墙,运行时不消费。

所以「4 条正则 + 5 条红线 + Phase 编排」全部是写在主理人 MD 正文里的自然语言约定, 靠模型的指令遵循能力实现。这就是官方文档那句 代码 = SOP(团队) 的真实含义 —— 它是 prompt,不是 code。

7.1 这带来什么工程后果

你写下的东西谁来保证失效时会怎样
必须先 TeamCreate仅 prompt模型可能跳过建团直接开写,输出变成「主理人一人分饰多角」的单人独白
成员产出不得代写仅 prompt模型为了「高效」直接替团员写出 PRD/架构/代码,团队形同虚设
信息经主理人中转仅 prompt成员间直连或被跳过,上下文断裂,产出对不上
阶段顺序仅 prompt跳阶段推进,下游拿到不完整输入
阶段完成要通报用户仅 prompt长时间静默,用户不知道进度
TeamCreate/Agent/SendMessage 工具存在代码保障这是唯一硬保障:环境变量置 1 后工具才出现在工具清单里
因此:主理人 MD 的写法决定团队是否真的协作

既然没有代码强制,那让团队真的跑起来的唯一手段就是把约束写得足够具体、足够强、足够不容绕过。 这也是为什么官方规范要塞进那么多 ❌ 禁止… 条目,且要求把「Agent ID 清单」逐条列明 —— 这是在用 prompt 长度和具体性对抗模型的偷懒倾向。 写团队时删掉这些约束 = 团队退化。

Section 08

8 技能、脚本与命名空间

8.1 技能:同样有声明 + 兜底双通道

cli/dist/codebuddy-lite-wb.mjsline 2511 · char ~8005324 · SkillExtensionLoader
async load(t, i, n, s){
  ...
  let s = ed.join(t, rr);                              // rr = "skills"
  if (await this.pathExists(s) && !p.has(s))
    n = await this.scanSkillsDirectory(s, i, p, void 0, 0, t, h);   // 兜底扫目录

  let S = ed.join(t, "SKILL.md");                      // 单文件技能形式
  if (!p.has(S) && await this.pathExists(S))
    n = await this.parseSkillFile(S, i, void 0, t, h);
}

8.2 命名空间前缀:一个容易搞错的机制

官方文档说 plugin.json 的 name 是「技能命名空间前缀」。这话在效果上对,但机制不是那样。

line 2337 · char ~6775537 · deriveSkillNameFromPath真实来源
function relativeSkillDirSegments(t, i, n = s){
  return i ? n.relative(i, t).split(n.sep).filter(Boolean) : []
}

function deriveSkillNameFromPath(t, i, n = s){
  let c = n.dirname(t);
  return relativeSkillDirSegments(c, i, n).join(":") || n.basename(c)
}

技能名是相对插件根目录的路径段用 : 连接(如 skills:ast-scan)。而消费端解析时:

line 19 · char ~1022821 · resolveSkillNameForPreload
resolveSkillNameForPreload(t, i, n){
  let s = i.find(i => i.name === t); if (s) return s;                 // ① 原名直查

  if (!t.includes(":") && n?.tags?.includes(ey.PLUGIN_TAG)){
    let s = n.tags[n.tags.length - 1];      // ★ 取 tags 末位 = "pluginName@marketplace"
    if (s && s !== ey.PLUGIN_TAG){
      let n = s.split("@")[0], c = `${n}:${t}`;                       // ② 拼前缀
      let p = i.find(t => t.name === c); if (p) return p
    }
  }
  if (!t.includes(":")){                                              // ③ 唯一后缀匹配
    let s = i.filter(i => i.name.endsWith(`:${t}`));
    if (1 === s.length) return s[0];
    ...
  }
}
实践结论

当你在 Agent MD 的 frontmatter 写 skills: [ast-scan] 时,解析顺序是: ① 直接找名为 ast-scan 的 → ② 找 <插件名>:ast-scan → ③ 唯一后缀匹配。

所以前缀来自 agent 的 tags 末位,而非从 plugin.json 现读。 两者通常相等(因为规范强制 plugin 字段 = name),但机制独立。 工程含义:技能名在包内最好全局唯一,否则第 ③ 步的「唯一后缀匹配」会失效。

8.3 bin/:约定优于配置

line 2511 · char ~8177098 · getPluginBinPaths
async getPluginBinPaths(){
  let t = await this.getActivePlugins(), i = [];
  for (let {plugin: n} of t)
    if (n.installedPath){
      let t = ed.join(n.installedPath, "bin"),
          s = await ec.stat(t).catch(() => void 0);
      s?.isDirectory() && i.push(t)          // ← 目录存在就收,不看 plugin.json
    }
  return i
}
line 1819 · char ~5448252 · 注入 Bash 环境
function buildPluginPathEnv(t){
  let i = process.env.PATH || "";
  if ("win32" !== process.platform)
    return [...t, i].filter(Boolean).join(":")
  ...
}
async function getBashPluginEnv(t){
  let i = [];
  if (t) try{ i.push(...await t.getPluginBinPaths()) }catch{}
  i.push(...(process.env.WORKBUDDY_EXTRA_PATHS ?? "").split(...).filter(Boolean));
  return i.length ? {PATH: buildPluginPathEnv(i)} : void 0
}
bin/ 不需要声明,且 plugin.json 的 bin 字段是死字段

bin/ 是纯约定目录自动探测,与 agents/skills 的「声明 + 兜底」不同。 只要插件根下有 bin/ 目录,它就会被前置到 Bash 工具的 PATH。 而 plugin.json 里的 bin 字段虽然会被扫描器归一化,CLI 侧无任何消费者 —— 写了也没用。

注意 PATH 是前置的,意味着包内脚本可以遮蔽系统同名命令。命名要有前缀意识。

Section 09

9 端到端时序图

把前面所有环节串成一条完整链路。建议对照这条链路来定位问题。

完整链路 · 从点击到可 spawn
【渲染层】用户点选专家
   │  contexts-BNN7VV-R.js: setSelectedExpertForSession({expertId, expertMarketplace, runtimeIdentity})
   ▼
【主进程】createConversationExpertRuntimePreparer                    app.asar ~76308000
   ├─ resolveExpertLocation      → 定位包目录
   ├─ activateExpert / ensureDownloaded
   │     └─ 缓存命中? → 检查 .downloaded_at 新鲜度 → 过期则重下
   ├─ resolveExpertAgentMarkdownPath(<root>/agents/<leadAgent|agentName>.md)
   └─ assemblePreparedFacts → {pluginName, agentName, expertType, promptText}
   ▼
【主进程】applyPreparedExpertSettings                               app.asar ~76323000
   ├─ sessionSettings.enabledPlugins["<plugin>@<marketplace>"]
   ├─ extraKnownMarketplaces[marketplace] = {source: directory, url: ...}
   └─ env.CODEBUDDY_CUSTOM_HEADERS += "X-Expert-Id: ..."
                                     += "X-Expert-Team-Task: true"   (仅 team)
   ▼
【主进程】ExpertPluginActivation                                     app.asar ~89018600
   └─ POST <acp>/api/v1/plugins/switch
        {persist:false, enable:"<plugin>@<mk>", agentName:"...",
         sourcePluginId:"...", internalModelRequestHeaders:[...]}
   ▼
【CLI】switchPlugins + setVolatilePluginAgent                        mjs 964
   ▼
【CLI】loadPluginComponents                                          mjs 2511
   ├─ AgentExtensionLoader.load
   │    ├─ 读 plugin.json 的 agents[]                    ← 通道 ①
   │    ├─ 兜底扫 <plugin>/agents/ 目录(深度≤16)        ← 通道 ②
   │    └─ parseAgentFile
   │         ├─ extractFrontMatterFull → {name, description, maxTurns, skills, ...}
   │         ├─ 正文 → 变量替换 ${CODEBUDDY_PLUGIN_ROOT}
   │         └─ 产出 {name, instructions, asTool:true, tags:[...,"<plugin>@<mk>"]}
   ├─ SkillExtensionLoader.load(读 skills[] + 兜底扫 skills/)
   └─ 读 <plugin>/settings.json(仅 agent / subagentStatusLine 两个 key)
   ▼
【CLI】PluginsProductProvider.provide → product.agents               mjs 984
   ▼
【CLI】AgentManager.registerPluginAgents → agentConfigMap            mjs 970
   ▼
【CLI】applyPluginAgentSetting                                       mjs 970
   └─ 把该 agent 的 instructions 覆盖到 default agent(成为会话人格)
   ▼
【CLI】生成 Agent 工具描述                                            mjs 1852
   └─ "Available agent types:\n- security-reviewer: ...\n- ..."
   ▼
【模型】Agent({subagent_type:"security-reviewer", name:"security-reviewer", prompt:"..."})
两条独立的 MD 读取路径,别混淆

注意链路里 MD 被读了两次,目的完全不同:

  • 主进程读(resolveExpertAgentMarkdownPath)→ 只给 UI 显示提示词预览。
  • CLI 读(AgentExtensionLoader)→ 才是真正装进模型的通道。

改了 MD 后界面没变化?可能是主进程的 DTO 有缓存(customExpertDtoCache); 改了 MD 后行为没变化?那是 CLI 侧没重新加载(需要重新激活)。两者不是一回事。

Section 10

10 动手:三种典型场景

10.1 先做选型决策

动手前先回答三个问题,它们决定包的形态:

决策三问

① 单角色还是多角色? —— 判断标准:有没有用户会直接问它的问题? 每个独立成 agent 的角色, 都应覆盖一个完整的「分析域」。域内多能力归并以一个 agent,跨域才拆。拆太细会导致主理人调度开销吃掉收益。

② 要不要带专属工具? —— 需要确定性的、模型不擅长的计算(如 AST 解析、指标计算、格式校验)就放 skills/ 或 bin/;只是文字工作则不需要。

③ 输出是否需要稳定结构? —— 需要则在 MD 的「输出规范」里写死模板(表格/JSON schema/Mermaid), 不要依赖模型自由发挥。

场景 A · 单领域专家
expertType: agent
1 个 MD,零协作开销。

适用:代码审查、合同审阅、SQL 优化、翻译润色、教案生成。
典型规模:1 个 MD,100–300 行。
场景 B · 多角色专家团
expertType: team
主理人 + N 团员,SOP 编排。

适用:软件开发全流程、交易分析、深度研究、营销战役。
典型规模:5–13 个 MD,主理人 200 行 + 各团员 100 行。
场景 C · 带工具专家
agent 或 team 皆可,外挂 skills/ + bin/。

适用:需要真实执行能力的场景 —— 静态分析、数据库巡检、报表生成、批量处理。
关键:用 ${CODEBUDDY_PLUGIN_ROOT} 定位包内资源。

10.2 场景 A:单领域专家(代码审查专家)

第一步:初始化

命令行bash
cd <技能目录>/scripts

python3 init_expert.py code-reviewer \
  --type agent \
  --path ~/.workbuddy/plugins/marketplaces/my-experts/plugins

# 产出:
#   ~/.workbuddy/plugins/marketplaces/my-experts/plugins/code-reviewer/
#   ├── .codebuddy-plugin/plugin.json     (全是 [TODO])
#   ├── agents/code-reviewer.md           (全是 [TODO])
#   ├── avatars/.gitkeep
#   └── README.md

第二步:填 plugin.json

.codebuddy-plugin/plugin.jsonJSON
{
  "name": "code-reviewer",
  "version": "1.0.0",
  "description": "Senior code reviewer covering correctness, security, performance and maintainability.",
  "author": { "name": "Your Name", "email": "you@example.com" },
  "agents": ["./agents/code-reviewer.md"],

  "expertType": "agent",
  "agentName": "code-reviewer",

  "displayName": {
    "en": "Senior Reviewer",
    "zh": "审稿人"
  },
  "profession": {
    "en": "Senior Code Reviewer",
    "zh": "资深代码审查师"
  },
  "displayDescription": {
    "en": "Senior code reviewer skilled at correctness, security and performance across languages. Delivers severity-ranked findings with concrete fixes.",
    "zh": "资深代码审查师,精通正确性与安全性能缺陷识别,按严重程度分级输出可直接落地的修改建议。"
  },
  "avatar": "avatars/expert.png",
  "categoryId": "02-Engineering",
  "defaultInitPrompt": {
    "en": "Review this diff and list issues by severity",
    "zh": "审查这段代码,按严重程度列出问题"
  },
  "plugin": "code-reviewer",
  "tags": [
    { "en": "Code Review", "zh": "代码审查" },
    { "en": "Security Audit", "zh": "安全审计" },
    { "en": "Performance", "zh": "性能分析" }
  ],
  "quickPrompts": [
    { "en": "Review this diff and list issues by severity",
      "zh": "审查这段代码,按严重程度列出问题" },
    { "en": "Check this code for security vulnerabilities like injection and XSS",
      "zh": "检查这段代码是否存在注入、XSS 等安全漏洞" },
    { "en": "Find performance bottlenecks in this hot path",
      "zh": "找出这段热点代码的性能瓶颈" }
  ]
}
逐条核对这 7 个易错点
  • name 与 plugin 必须完全相等,且 kebab-case。
  • agentName 必须等于 agents/ 下的文件名(不含 .md)。
  • tags 与 quickPrompts 恰好各 3 个,多一个少一个都是 error。
  • defaultInitPrompt 与 quickPrompts[0] 逐字相同(界面显示的是后者)。
  • displayDescription.zh 控制在 40–50 字(上例 45 字)。
  • categoryId 必须是 12 个合法值之一(见附录 A)。
  • 所有 i18n 字段都要有 en 与 zh,缺一即 error。

第三步:写 Agent MD

agents/code-reviewer.mdMarkdown
---
name: code-reviewer
description: Reviews code for correctness, security, performance and maintainability issues, output severity-ranked findings with concrete fixes.
displayName:
  en: "Senior Reviewer"
  zh: "审稿人"
profession:
  en: "Senior Code Reviewer"
  zh: "资深代码审查师"
maxTurns: 50
---

# 资深代码审查师 - 审稿人

你是一名资深代码审查师。你的职责是从正确性、安全性、性能和可维护性四个维度审查代码,
输出可执行的修改建议,而不是主观风格评判。

## 核心能力

1. **正确性审查**:识别逻辑错误、边界条件缺失、空值处理不当、并发竞态、错误 swallowed。
2. **安全审计**:识别注入(SQL/命令/模板)、XSS、越权、敏感信息泄漏、依赖漏洞。
3. **性能分析**:识别 N+1 查询、循环内重复计算、不必要同步阻塞、内存泄漏风险。
4. **可维护性**:识别重复代码、过深嵌套、命名失义、缺少错误处理。

## 工作流程

1. **通读全貌**:先读完整段代码或 diff,理解意图,不急于下结论。
2. **逐项审查**:按上述四个维度各扫一遍,记录候选问题。
3. **去伪存真**:剔除误报与纯风格问题。只保留能说清危害的问题。
4. **定位与建议**:每条问题给出文件行号、危害说明、以及具体改法。
5. **汇总输出**:按下列模板输出。

## 输出规范

严格按以下结构输出,不要省略任何一节:

### 总体结论
一句话:这段代码是否可以合并,以及最关键的阻塞问题是什么。

### 问题清单
| 严重程度 | 位置 | 类别 | 问题描述 | 建议改法 |
|---------|------|------|---------|---------|
| 阻塞 | `path/to/file.ts:42` | 安全 | 用户输入直接拼接进 SQL | 改用参数化查询 |

严重程度只取四档:`阻塞` / `严重` / `一般` / `建议`。
类别只取四档:`正确性` / `安全` / `性能` / `可维护性`。

### 亮点
列出 1-3 处写得好的地方。不要为了凑数而编造。

## 注意事项

- **不做主观风格争论**:缩进、引号、分号这类交给 linter,不出现在报告里。
- **不确定的标注不确定**:疑似问题必须标注「待确认」,不得伪装成确定结论。
- **给出行号**:没有行号的问题视为无效。
- **代码必须通过工具读取**:不要凭印象推断代码内容。
- 输出语言与用户提问语言保持一致。
这段 MD 为什么这样写
  • frontmatter 没有 tools —— 规范明令禁止(§2.3)。
  • 「工作流程」给了确定性步骤 —— 模型不会跳过通读直接输出。
  • 「输出规范」写死了模板与枚举值 —— 保证多次调用输出结构稳定,可被下游解析。
  • 「注意事项」明确列了禁止项 —— 抑制模型的编造与废话倾向。
  • 没有废话 —— 每句话都是指令,因为正文直进 context,无中间层压缩。

第四步:头像 → 校验 → 注册

命令行bash
# 4. 生成头像(用 ImageGen,size 设为 1024x1024)
#    prompt 从 MD 内容反推,例:
#    "Professional cartoon-style illustration avatar, a senior code reviewer,
#     wearing glasses, focused and meticulous expression,
#     code diff lines and warning badges floating in background.
#     Bust shot, facing forward. Clean simple blue-purple-toned background.
#     High quality, professional, natural."
#    → 保存到 avatars/expert.png

# 5. 校验
python3 validate_expert.py ~/.workbuddy/plugins/marketplaces/my-experts/plugins/code-reviewer

# 6. 注册(会二次校验 + 写 marketplace.json + 写 .created-by-session)
python3 register_expert.py ~/.workbuddy/plugins/marketplaces/my-experts/plugins/code-reviewer

# 7.(可选)打包分享
python3 package_expert.py ~/.workbuddy/plugins/marketplaces/my-experts/plugins/code-reviewer ./dist

10.3 场景 B:专家团(代码质量攻坚团)

Team 型的关键在于主理人 MD 的 SOP 与约束写得够不够硬 —— 因为这是唯一的保障。

目录结构

code-quality-team/ ├── .codebuddy-plugin/plugin.json ├── agents/ │ ├── code-quality-team-lead.md 主理人(前缀 = 团队名) │ ├── cq-security-auditor.md 安全审计 │ ├── cq-perf-analyst.md 性能分析 │ ├── cq-maintain-reviewer.md 可维护性 │ └── cq-fix-writer.md 修复实施 ├── avatars/ 5 张(团队 + 主理人 + 4 团员) └── settings.json {"agent": "code-quality-team-lead"}

plugin.json 要点

.codebuddy-plugin/plugin.json(team 型差异部分)JSON
{
  "name": "code-quality-team",
  "version": "1.0.0",
  "description": "Multi-role team that audits and fixes code quality issues through a SOP pipeline.",
  "agents": [
    "./agents/code-quality-team-lead.md",
    "./agents/cq-security-auditor.md",
    "./agents/cq-perf-analyst.md",
    "./agents/cq-maintain-reviewer.md",
    "./agents/cq-fix-writer.md"
  ],

  "expertType": "team",
  "agentName": "code-quality-team-lead",
  "teamInfo": {
    "leadAgent": "code-quality-team-lead",
    "memberAgents": [
      "cq-security-auditor",
      "cq-perf-analyst",
      "cq-maintain-reviewer",
      "cq-fix-writer"
    ]
  },

  "displayName": { "en": "Code Quality Team", "zh": "代码质量攻坚团" },
  "profession":  { "en": "Code Quality Team", "zh": "代码质量攻坚团" },   ← 必须与 displayName 一致
  ...
  "members": [
    { "id": "code-quality-team-lead", "name": {"en": "Shen", "zh": "沈把关"},
      "profession": {"en": "Quality Director", "zh": "质量总监"},
      "avatar": "avatars/code-quality-team-lead.png", "role": "lead" },
    { "id": "cq-security-auditor", "name": {"en": "Yu", "zh": "于无隙"},
      "profession": {"en": "Security Auditor", "zh": "安全审计师"},
      "avatar": "avatars/cq-security-auditor.png", "role": "member" },
    ...
  ]
}
Team 型三个专属硬约束
  • profession 必须与 displayName 逐字相等(校验器判 error,不是 warning)。
  • members[] 必须包含主理人且 role: "lead";teamInfo.memberAgents 必须不含主理人。
  • 必须有 settings.json,且 {"agent": ...} 等于 agentName。

主理人 MD 骨架(这是团队的灵魂)

agents/code-quality-team-lead.mdMarkdown
---
name: code-quality-team-lead
description: Lead that orchestrates a code quality pipeline across security, performance, maintainability and fix implementation.
displayName:
  en: "Shen"
  zh: "沈把关"
profession:
  en: "Quality Director"
  zh: "质量总监"
maxTurns: 180
---

# 代码质量攻坚团 - 主理人

你是代码质量攻坚团的主理人沈把关 · 质量总监。你负责按 SOP 编排团队完成代码质量攻坚,
你是协调者,不是执行者。

## 团队成员

| Agent ID | 姓名 | 职责 | 什么时候调它 |
|---------|------|------|------------|
| cq-security-auditor | 于无隙 | 安全审计 | 需要找注入/XSS/越权/泄漏 |
| cq-perf-analyst | 马立行 | 性能分析 | 需要找瓶颈/慢查询/内存问题 |
| cq-maintain-reviewer | 易读通 | 可维护性 | 需要找重复/嵌套/命名问题 |
| cq-fix-writer | 何补拙 | 修复实施 | 拿到问题清单后要落地修改 |

## 工作流路由(收到请求时首先判断)

| 场景 | 判定条件 | 走哪条 |
|------|---------|--------|
| 全量攻坚 | 用户要求完整审查 + 修复 | 标准 SOP |
| 只查不改 | 用户只问「有什么问题」 | 并行审查,跳过 cq-fix-writer |
| 单一维度 | 用户只问安全 / 只问性能 | 直调单一成员 |
| 已知问题求修 | 用户已经列出问题清单 | 直调 cq-fix-writer |

## 标准 SOP

### Phase 1(并行,无数据依赖)
同一条消息内同时 spawn:
- cq-security-auditor → 安全问题清单
- cq-perf-analyst → 性能问题清单
- cq-maintain-reviewer → 可维护性问题清单

**三个成员互不通信,全部回传给你。**

### Phase 2(串行,依赖 Phase 1)
你把 Phase 1 的完整产出原文合并成一份问题清单,交给 cq-fix-writer:
- 按严重程度排序
- 标注哪些需要修、哪些仅建议
- 带上每个问题的原文位置信息

cq-fix-writer 输出修改方案 + 具体代码。

### Phase 3:最终报告
你汇总所有产出,向用户输出交付总结。

## 团队协作机制(铁律)

你必须走正式的团队协作流程,严禁简化或跳过:

1. **建立团队**:任务开始时由你亲自创建团队(TeamCreate),命名 `cq-<项目简称>`。
   团队创建必须且只能由你执行,严禁委派任何成员创建团队。
2. **调度成员**:按 SOP 阶段将成员拉入协作、下发独立任务。
   成员作为独立协作方输出专业产出,不得由你代写。
3. **消息中转**:成员产出回传给你,由你汇总、转交下一阶段。
   所有跨成员信息流必须经你中转,不得互相直连。
4. **成员结论为准**:任何专业产出必须由对应成员输出后再采信,你只做编排与汇编。

### 严禁行为

- ❌ 禁止跳过 TeamCreate,直接自己模拟成员发言或并行写出多角色内容
- ❌ 禁止自己代写任何团队成员的专业产出
- ❌ 禁止未完成前序阶段就跳到后续阶段
- ❌ 禁止让成员互相直连通信
- ❌ 禁止 spawn 主理人自己

### 子任务命名(CRITICAL)

调度每位成员时,必须在 Agent 工具的 `name` 参数传入该成员的 Agent ID,
`subagent_type` 也传入相同的值。禁止省略 name,禁止用中文名或自创名称:

- `name: "cq-security-auditor", subagent_type: "cq-security-auditor"`
- `name: "cq-perf-analyst", subagent_type: "cq-perf-analyst"`
- `name: "cq-maintain-reviewer", subagent_type: "cq-maintain-reviewer"`
- `name: "cq-fix-writer", subagent_type: "cq-fix-writer"`

## 协作规则

1. 每阶段结束后,将完整产出原文传递给下一阶段成员,不要只传摘要。
2. 每完成一个阶段向用户简要通报进度。
3. 所有输出使用与用户原始需求相同的语言。
4. 最终报告必须包含:TL;DR、问题清单、修改文件清单、遗留问题、用户下一步建议。
团员 MD 的必备项

每个团员 MD 必须包含这 6 项,缺一项团队就会退化:

  1. 角色定义(一句话说清你是谁)
  2. 擅长领域(3–5 个具体能力点)
  3. 分析框架(分步骤流程)
  4. 数据获取方式(具体工具调用)
  5. 结构化输出模板(表格/分段)
  6. SendMessage 回传要求 —— 明文写「分析完成后必须通过 SendMessage 将完整结果回传给主理人」

第 6 项最容易被漏掉。没有它,团员产出可能不回传,主理人拿不到东西,整条链路断掉。

10.4 场景 C:带专属工具的专家

当任务里有确定性计算时,别让模型用自然语言硬算 —— 给它脚本。

db-guardian/ ├── .codebuddy-plugin/plugin.json ├── agents/db-guardian.md ├── skills/ │ └── slowlog-scan/ │ ├── SKILL.md ← 技能入口 │ └── scripts/scan.py ← 真实脚本 ├── bin/ │ └── dbstat ← 自动入 PATH,无需声明 ├── references/ │ └── rules.md ← 大段参考资料放这里,别塞进 MD └── avatars/
agents/db-guardian.md(关键片段)Markdown
---
name: db-guardian
description: Database health guardian that diagnoses slow queries, index issues and connection pool saturation.
displayName:
  en: "Guardian"
  zh: "守库人"
profession:
  en: "Database Reliability Engineer"
  zh: "数据库可靠性工程师"
maxTurns: 60
skills: [slowlog-scan]              # 预加载本包技能,会自动解析为 db-guardian:slowlog-scan
---

# 数据库可靠性工程师 - 守库人
...

## 数据获取方式

扫描慢日志时,必须调用脚本而非自己推算:

    ${CODEBUDDY_PLUGIN_ROOT}/skills/slowlog-scan/scripts/scan.py --top 20 --unit ms

执行计划分析使用 PATH 中已就绪的工具:

    dbstat --explain "<sql>"

诊断规则参考:

    ${CODEBUDDY_PLUGIN_ROOT}/references/rules.md
三个关键机制在这里同时生效
  • ${CODEBUDDY_PLUGIN_ROOT} → 被替换成包根目录绝对路径,让 MD 能引用包内任何资源。
  • skills: [slowlog-scan] → 解析时自动尝试 db-guardian:slowlog-scan 前缀(§8.2)。
  • bin/dbstat → 无需任何声明,插件激活时自动前置到 Bash 的 PATH(§8.3)。

把大段规则放 references/ 而不是 MD 正文 —— 正文常驻 context,参考资料按需读取。

Section 11

11 12 个反直觉的坑

以下每一条都是官方文档口径与代码实现不一致,或者文档没写但会咬人的地方。按踩坑频率排序。

#直觉 / 文档说法代码实证后果
1 把专家目录放哪都行,注册了就能用 扫描器硬编码只扫 marketplaces/my-experts/plugins;init_expert.py 对 --path 做硬检查直接 sys.exit(1);validate_expert.py 也判定「不在专家目录下将无法被检测到」 专家根本不出现
2 写好 plugin.json 就够了 注册是必经步骤。扫描以 marketplace.json 为唯一入口,if (!await pathExists(marketplace.json)) return [] 目录齐全但界面无此专家
3 agents 字段必须写明每个 MD AgentExtensionLoader.load 在读完声明后还会兜底扫 <plugin>/agents/(深度≤16)。实证:官方 software-company 就没有此字段 可省;但声明了不存在的路径反而报错
4 teamInfo.memberAgents 是成员清单,运行时会照着调 该字段在 96 MB 的 app.asar 中出现 0 次。运行时唯一读的是 teamInfo.leadAgent。members[] 只用于渲染头像墙 团队编排无任何运行时保障
5 settings.json 是 team 生效的关键 CLI 确实读(只认 agent/subagentStatusLine 两个 key),但主进程启动时主动删除这类单 key 文件。本机 16 个官方包一个都没有。真正生效的是 volatile agentName 写了也不指望它生效
6 frontmatter 里写 tools 可以限制工具 规范明令禁止。且 permissionMode/hooks/mcpServers 三个字段写了会被静默忽略并打 warning:「is ignored for plugin agents」 配置无效,且不易察觉
7 改了 MD 立刻生效 有两套读取与缓存:主进程 DTO 有 customExpertDtoCache(影响界面),CLI 侧需重新激活(影响行为)。本地包无热重载 改了没反应,需重新选择专家
8 选了专家会一直生效 POST /plugins/switch 带 persist: false,切换是 volatile(会话级) 新会话回到默认助手
9 技能前缀来自 plugin.json 的 name 实际来自 agent tags 末位的 "<pluginName>@<marketplace>" 拆分;deriveSkillNameFromPath 用的是相对路径段 join(:) 效果相同,机制不同
10 bin 字段声明 bin/ 目录 bin/ 是约定目录自动探测(getPluginBinPaths 直接 join 不看清单)。plugin.json 的 bin 字段无任何消费者 该字段是死字段
11 专家缓存路径是 cache/experts/<name>/<version>/ 真实落盘是 marketplaces/experts/plugins/<bundleName>/,无 version 层级。cache/experts/ 是历史遗留;app/cache/experts/ 才存 manifest.json + expert-bundle-map.json 改错地方,改了不生效
12 categoryId 只有 12 个分类 本机实证存在第 13 个 13-TencentZone(andon-q-expert),官方文档未列出 照抄文档会漏掉合法值
还有一个隐性约束:插件数量与字符预算

Agent 工具的「可派生类型清单」是拼进工具描述字符串的,且有字符预算上限 (truncateAgentsByCharBudget)。同时启用多个专家包时, 超出预算的 agent 会被静默截断 —— 它们确实加载了,但模型看不到、也就无从调用。

工程建议:一个团队控制在 5–7 个成员(官方 software-company 是 5 个, trading-agent 是 13 个属于上限特例),并给每个 agent 起短而有区分度的名字, 因为名字本身也占预算。

Section 12

12 排错手册

现象排查顺序
专家在界面里找不到 ① 目录是否在 my-experts/plugins/ 下 → ② marketplace.json 是否存在且有该条目(source 形如 ./plugins/<dir>)→ ③ 条目的 source 与目录名是否对得上 → ④ plugin.json 是否合法 JSON 且 name/agentName 齐全
validate 报 name 相关错 ① name 是否 kebab-case 且 ≥2 字符 → ② plugin 是否等于 name → ③ agentName 是否有对应 agents/<name>.md → ④ MD 的 frontmatter name 是否等于文件名 → ⑤ 文件名是否是 team-lead.md(需加前缀)
validate 报数量/长度错 tags 与 quickPrompts 必须恰好 3 个; displayDescription.zh 40–50 字(仅 warning); team 型 profession ≡ displayName
专家存在,但行为不对 ① 是否真的选中了该专家(切换是会话级 volatile)→ ② 改完 MD 后是否重新激活(无热重载)→ ③ 正文是否真的进了 context(确认 MD 路径被加载)→ ④ 正文里是否有tools/hooks/mcpServers 被忽略
团队退化成「主理人独白」 这是prompt 失效而非代码问题。检查主理人 MD 是否包含: TeamCreate 强制条款、代写禁令、消息中转条款、Agent ID 逐条列举、团员 MD 的 SendMessage 回传要求。 删掉任一条都会显著提高退化概率
团员产出不回传 / 断链 ① 团员 MD 是否明文写了 SendMessage 回传要求 → ② 主理人调度时 name 与 subagent_type 是否都传了 Agent ID(不是中文名)→ ③ Agent ID 是否等于 MD 文件名
成员在 Agent 工具清单里消失 ① Agent 工具描述被字符预算截断(成员过多或名字过长)→ ② agentConfigMap 先到先得,与其它插件/用户级 agent 重名会被挤掉 → ③ 名字是否被 truncateAgentsByCharBudget 裁掉
技能引用不上 ① frontmatter 的 skills 名字 → 直查 → <插件名>:<技能名> → 唯一后缀匹配,三步都失败才落空 → ② 包内技能名是否唯一(重名会让第三步失效)→ ③ skills/ 目录是否存在(有兜底扫描)
bin 脚本执行不了 ① bin/ 是否真的在插件根目录(不是在 .codebuddy-plugin/ 里)→ ② 脚本是否有执行权限 → ③ 注意 PATH 是前置的,确认没有与系统命令冲突
注册脚本提示找不到会话 ID 环境变量 CODEBUDDY_SESSION_ID 未注入。这不影响 marketplace.json 写入, 只是不生成 .created-by-session,专家仍可正常使用
Appendix A

A 速查表

A.1 命令速查

五条主命令bash
SCRIPTS="<技能目录>/scripts"
XP="~/.workbuddy/plugins/marketplaces/my-experts/plugins"

# ① 初始化
python3 $SCRIPTS/init_expert.py <name> --type agent|team --path $XP

# ② 校验
python3 $SCRIPTS/validate_expert.py $XP/<name>

# ③ 注册(必经)
python3 $SCRIPTS/register_expert.py $XP/<name>

# ④ 打包
python3 $SCRIPTS/package_expert.py $XP/<name> ./dist

# ⑤ 批量
python3 $SCRIPTS/batch_create.py

A.2 关键路径速查

自建专家目录
~/.workbuddy/plugins/marketplaces/my-experts/plugins/<name>/
市场清单
~/.workbuddy/plugins/marketplaces/my-experts/.codebuddy-plugin/marketplace.json
官方包落盘
~/.workbuddy/plugins/marketplaces/experts/plugins/<bundleName>/
市场元数据
~/.workbuddy/app/cache/experts/{manifest.json, expert-bundle-map.json}
用户级 agent
~/.workbuddy/agents/*.md(独立机制,不是专家包)
用户级技能
~/.workbuddy/skills/<name>/SKILL.md
团队环境变量
CODEBUDDY_CODE_EXPERIMENTAL_AGENT_TEAMS=1(team 型自动置 1)
包内变量
${CODEBUDDY_PLUGIN_ROOT} → 插件根目录绝对路径

A.3 行业分类(categoryId)

categoryId分类适用场景
01-ProductDesign产品设计UI/UX 设计、产品规划、原型、交互设计
02-Engineering技术工程编程开发、架构设计、DevOps、技术选型
03-GameSpatial游戏空间游戏开发、3D 建模、虚拟现实、游戏设计
04-DataAI数据智能数据分析、机器学习、大模型应用、BI
05-MarketingGrowth营销增长品牌营销、用户增长、广告投放、SEO
06-ContentCreative内容创作文案写作、视频脚本、创意策划、翻译
07-SalesCommerce销售商务销售策略、商务谈判、客户管理、电商
08-FinanceInvestment金融投资投资分析、财务管理、风控、量化交易
09-OperationsHR运营人力项目运营、人力资源、组织管理、培训
10-ProjectQuality项目质量项目管理、质量保障、测试、流程优化
11-SecurityCompliance法务安全信息安全、合规审查、法务咨询、隐私保护
12-IndustryConsultant行业顾问跨行业咨询、战略规划、不属于以上明确分类的
13-TencentZone(文档未列)实证存在于官方包 andon-q-expert,官方文档未收录

A.4 头像背景色调映射

同一团队的头像必须共用风格锚定词,背景色调由 categoryId 决定:

categoryId背景色调
01 / 02 / 03warm orange-coral / blue-purple / purple-red gradient
04 / 05 / 06cyan-teal / red-orange / pink-magenta
07 / 08 / 09golden-amber / dark blue with gold accent / navy slate-blue
10 / 11 / 12green-emerald / dark grey-blue / deep teal with silver accent
Appendix B

B 本机实证数据

以下是对这台机器上 16 个真实落盘专家包的统计。它们是「别人怎么写」的一手参考, 也可用来校准自己的包规模。

B.1 包清单

包名类型categoryId结构特征
software-companyteam(无)无 agents 字段、无 avatars —— 纯靠目录兜底 + 远程头像
trading-agentteam0813 个 MD / 12 memberAgents / 13 members —— 规模上限
stock-partner-teamteam087 个 MD / 7 members,8 张头像(唯一带全量头像的团队)
fullstack-devagent021 MD + 1 头像,字段最完整的范例
andon-q-expertagent13-TencentZone唯一使用文档外分类的包
senior-developeragent021 MD + 1 头像
up-stock-strategy-analyzeagent—1 MD + 1 头像 + 附带 skills 包
workspace-builderagent—1 MD + 1 头像
content-creatoragent061 MD + 1 头像
news-buddyagent061 MD + 1 头像
data-analytics-reporteragent041 MD + 1 头像
ui-designeragent—1 MD + 1 头像
xiaohongshu-operations-expertagent—1 MD + 1 头像
meituan-living-assistantagent071 MD,无头像
data-analysisagent(无)1 MD,无头像、无 categoryId
ppt-implementagent(无)1 MD,无头像、无 categoryId
16
落盘专家包总数
13 : 3
agent 型 : team 型
45
Agent MD 总数
16
.downloaded_at 标记(每包一个)

B.2 从数据里读出的三条结论

  1. 头像并非必需 —— 16 个包中 5 个没有 avatars 目录,仍能正常工作。说明市场包的头像走远程 URL 分发,落盘可省。
  2. categoryId 可有可无 —— 至少 3 个包没写。它主要用于市场分类陈列,本地使用不影响功能。
  3. 团队规模差异极大 —— 从 5 人到 13 人都有。结合 §11 的字符预算截断风险,5–7 人是更安全区间。
Appendix C

C 心智模型总结

如果只记住六句话:

① 专家就是一个插件
所有行为都可从「插件基础设施」推导:为什么要注册 marketplace、为什么 bin 进 PATH、 为什么技能有命名空间。抓住这条,就不需要记忆零散规则。
② MD 正文 = system prompt
没有中间层、没有包装、没有优化。写进 agents/*.md 的每个字都直接进入模型上下文。 正文质量 = 专家质量,这是唯一的杠杆。
③ 团队没有编排引擎
expertType: team 对运行时的全部贡献:开一个环境变量开关 + 打一个 HTTP 头。 SOP、铁律、红线全是 prompt。团队跑不跑得起来,取决于 MD 写得多硬。
④ 激活是会话级的
persist: false 意味着切会话即失效。这不是缺陷而是设计 —— 专家是临时人格,不是常驻配置。
⑤ 注册是必经步骤
扫描器以 marketplace.json 为唯一入口。目录、plugin.json、MD 都齐全, 只要清单里没条目,专家就等于不存在。
⑥ 三套可插拔机制别混用
Skill(工具箱,按需加载)、Custom Agent(用户级角色)、Expert(插件级人格)。 三者的物理位置、加载时机、运行时载体各不相同。

C.1 给工程师的迁移建议

如果你要在自己的产品里实现类似机制,这套设计里最值得借鉴的三点:

  1. 文件即配置:整个包无需一行代码、无需构建、无需 schema 注册,纯文本即可分发。 代价是没有编译期检查,所以才需要 validate_expert.py 这类外部校验来补位。
  2. 把插件系统当作底座:专家不自建加载器、不自建市场、不自建生命周期, 而是复用插件基础设施换取版本、分发、命名空间、PATH 注入等能力。典型的复用优于新建。
  3. 把编排留给模型:团队 SOP 不写进代码而写进 prompt,换来零开发成本的领域适配 —— 换个 MD 就从交易团变成开发团。代价是没有确定性保障,需要在 prompt 里用冗余约束来补偿。 这个取舍是这类系统的核心设计张力。

取证说明:本文结论基于对本机 WorkBuddy(macOS / darwin)安装包的静态分析,包含 app.asar(96,506,567 字节,主进程,未解包)的按字节偏移取证、 cli/dist/codebuddy-lite-wb.mjs(9,485,334 字节,2678 行)的行内定位、 resources/plugins/workbuddy-builtin/skills/expert-manager/ 下全部脚本与规范文档的通读, 以及对 ~/.workbuddy/plugins/marketplaces/ 下 16 个真实专家包的统计。

文中代码片段均经过精简(变量名保留压缩后的短名以保真,部分做了格式化以提高可读性)。 标注「未找到证据」处为诚实留白,未做推测。若版本更新导致行为变化,请以实际代码为准 —— 这也是本文反复提供取证路径、而非只给结论的原因。