Commit 507ec3fae5169ad7dce2835936bd72eaeaf76e2e

Authored by yibo
1 parent 83afecbf

fix(agent-body): work 节点补齐 soul/tools/identity 等身份上下文,org node 角色定义截断改为可见

## 问题 1:work 节点只有 agent.md 进提示词

graph-engine 驱动 work 节点走 PromptBuilder.build(),只读 protocol + agent.md +
companyContextPath + user.md/memory.md/近期记忆。交互式的 agent-chat.js
(buildSystemPrompt)另有一条独立实现,会读 soul.md/identity.md/tools.md/
heartbeat.md/OKR 目标/hippocampus 检索/main_skill/SKILL.md/其它 skills——两条链路
从未同步过。静默到什么程度:agent 照样跑、照样成功,只是少了人格/工具约束/长期
记忆检索,没有任何报错能看出原因。

新增 identity-context.js,抽出 agent-chat 那份逻辑里「读哪些文件」的等价实现(不
读 agent.md/user.md/memory.md/近期记忆——那五样已经被 PromptBuilder 覆盖,避免
重复),接进 PromptBuilder.build() 的非 SKILL.md 分支(scoped skill 那类不是完整
身份,不适用)。用户明确选择「全量对齐」:soul/identity/tools/heartbeat/OKR/
hippocampus/skills 一次性全补,不做裁剪版。

ponytail:没有反过来改 agent-chat.js 去调用这份共享实现——两处现在是「同构但
独立」的两份代码。agent-chat.js 是长期稳定的高频路径,为这次修复去动它风险和
收益不成比例,见 identity-context.js 头注的升级路径。

## 问题 2:org node 角色定义截断到 2000 字,切没切完全不可见

describeRoleDefinition 给 workgraph-planning 的角色定义摘录一直硬切前 2000 字
(有意为之,规划要的是「职能」不是全文)。但切没切、切掉了多少,规划 agent
一无所知——agent.md 写得长一点、职能说明排在后半段,这句关键描述可能根本没
进提示词,且没有任何信号。截断上限不变,改成截断时附一句 ⚠️ 提示 + 完整路径,
给它一条自己去读全文的退路。

## 测试

identity-context.test.js(10 条):文件读取的每个条件分支、OKR/hippocampus
只在有 agentId 时才查、OKR 抛错不拖累其它内容、skills 目录扫描排除 main_skill。
两个依赖(OkrService/retrieveRelevantPlans)用直接换 CJS module.exports 属性
的手法隔离——本仓库服务层是纯 CommonJS,vi.mock 对嵌套 require() 链路不生效
(实测踩过),且这两个依赖没有 env 覆盖点,真实路径不能进单测。

describe-role-definition-truncation.test.js(3 条):截断/不截断/本体不存在
三种情况,用真实 system/agents/ 下的 zz- 前缀临时本体(locator.bodyDir 没有
env 覆盖点,只能顺着真实路径走),afterEach 清理不留痕迹。

全量 npx vitest run:150 文件 / 1378 用例,零回归。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/services/graph-engine.js
... ... @@ -919,8 +919,17 @@ function describeRoleDefinition(ctx) {
919 919 for (const f of candidates) {
920 920 try {
921 921 if (!fs.existsSync(f)) continue;
922   - // 只给开头一段:规划要的是「这个节点负责什么职能」,不是它的完整工作流
923   - return `- 角色定义摘录(${path.relative(REPO_ROOT, f)}):\n${fs.readFileSync(f, 'utf8').slice(0, 2000)}`;
  922 + // 只给开头一段:规划要的是「这个节点负责什么职能」,不是它的完整工作流。
  923 + // 但截断必须**可见**——原来悄悄切掉后半段,agent.md 写得长一点(常见于职能说明
  924 + // 排在文件后半的本体),「这个节点负责什么职能」这句关键描述就可能根本没进提示词,
  925 + // 而 workgraph-planning 拿不到信号、只会按看到的那半截自行推断,不会知道自己漏看了。
  926 + // 加一句提示 + 完整路径,给它一个自己去读全文的退路,不是把上限拉大(2000 仍是故意的)。
  927 + const full = fs.readFileSync(f, 'utf8');
  928 + const excerpt = full.slice(0, 2000);
  929 + const truncNote = full.length > 2000
  930 + ? `\n(⚠️ 原文共 ${full.length} 字,已截断到前 2000 字——如果上面没读到「这个节点负责什么职能」,说明它在后半段,去读完整文件 ${f})`
  931 + : '';
  932 + return `- 角色定义摘录(${path.relative(REPO_ROOT, f)}):\n${excerpt}${truncNote}`;
924 933 } catch { /* 读不动就换下一个候选 */ }
925 934 }
926 935 return '- 角色定义:未找到该本体的 agent.md,请按节点名推断职能,并在规划记录里写明这一项缺失。';
... ... @@ -4379,6 +4388,9 @@ module.exports = {
4379 4388 // W9:失败上下文段的构造与它的数据源。describeFailureContext 的真实调用方是 advanceOne,
4380 4389 // 导出只是让单测能不起真 CLI 直接验这一段文本(同 ensureProjectRoot / diffRepoStatus 的口径)。
4381 4390 describeFailureContext,
  4391 + // 「本体装配静默坑」修复:截断可见性只有一条判据(截断了就必须带⚠️提示),
  4392 + // 导出给单测直接验,同 describeFailureContext 的口径。
  4393 + describeRoleDefinition,
4382 4394 LOOP_HARD_CAP_EDGE,
4383 4395 LOOP_HARD_CAP_RUN,
4384 4396 };
... ...
server/src/core/identity-context.js 0 → 100644
  1 +/**
  2 + * 本体身份上下文(soul/identity/tools/heartbeat/OKR/hippocampus/skills)的共用读法。
  3 + *
  4 + * ── 修的是哪个坑 ──────────────────────────────────────────────────────────
  5 + * graph-engine 驱动的 work 节点(`agent_call`)此前只有 `agent.md` 进提示词——
  6 + * `PromptBuilder.build()` 走的是「protocol + agent.md + companyContextPath +
  7 + * user.md/memory.md/近期记忆」这一条链路,`soul.md`/`identity.md`/`tools.md`/
  8 + * `heartbeat.md`/OKR 目标/hippocampus 检索/skills 全部缺失。而交互式的
  9 + * `agent-chat.js`(`buildSystemPrompt()`)一直都在读这些文件,两条链路从未同步过。
  10 + * 静默到什么程度:agent 照样跑、照样成功,只是少了人格/工具约束/长期记忆检索,
  11 + * 表现为「work 节点里的 agent 比 chat 里蠢一截」,没有任何报错或日志能看出原因。
  12 + *
  13 + * ── 这里只补缺的,不重复已有的 ────────────────────────────────────────────
  14 + * `agent.md` 由 `PromptBuilder.getAgentPrompt()` 已经读过;`user.md`/`memory.md`/
  15 + * 近期记忆由 `PromptBuilder.buildCompanyStateParts()` 已经读过——这个函数**不重复**
  16 + * 那五样,只读 agent-chat 有、PromptBuilder 没有的那几样。
  17 + *
  18 + * ── ponytail:没有让 agent-chat.js 反过来调用这份实现 ─────────────────────
  19 + * 两处现在是「同构但独立」的两份代码,不是真正共享一份——`buildSystemPrompt()`
  20 + * 是长期稳定的高频路径,为了这次修复去动它,风险和收益不成比例。真要收敛成
  21 + * 一份实现,得先给 `buildSystemPrompt()` 补一圈契约测试再动。升级路径:有余力
  22 + * 时把 agent-chat.js 里对应的读文件片段也换成调这份实现,届时可以直接删掉
  23 + * agent-chat.js 里那几段、只保留它自己特有的东西(company 环境上下文 / 运行时坐标
  24 + * 声明 / userMessage 相关的 hippocampus 搜索文本拼接)。
  25 + */
  26 +const fs = require('fs');
  27 +const path = require('path');
  28 +const { OkrService } = require('../../services/okr-service');
  29 +const { retrieveRelevantPlans } = require('../../services/memory-service');
  30 +
  31 +// 与 agent-chat.js 同款用法:全局 OKR 索引(不按公司分库),module 级单例。
  32 +const okrService = new OkrService();
  33 +
  34 +function readTrim(p) {
  35 + try { return fs.readFileSync(p, 'utf-8').trim(); } catch { return ''; }
  36 +}
  37 +
  38 +/**
  39 + * @param {object} a
  40 + * @param {string} a.bodyDir 本体目录(agent.md/soul.md/.claude/ 所在,只读)
  41 + * @param {string} [a.agentId] OKR/hippocampus 检索用的身份键;没有则跳过这两段
  42 + * @param {string} [a.searchText=''] hippocampus 检索用的当前任务文本(agent-chat 用的是
  43 + * userMessage + todo.md,这里用调用方传入的任务描述)
  44 + * @returns {string[]} 按顺序排好的提示词分段,调用方自己 push 进 parts 数组
  45 + */
  46 +function buildIdentityContextParts({ bodyDir, agentId, searchText = '' }) {
  47 + const parts = [];
  48 + if (!bodyDir) return parts;
  49 +
  50 + // soul.md / identity.md / tools.md —— agent.md 已经由调用方读过,这里不重复
  51 + for (const f of ['soul.md', 'identity.md', 'tools.md']) {
  52 + const c = readTrim(path.join(bodyDir, f));
  53 + if (c) parts.push(c);
  54 + }
  55 +
  56 + const heartbeat = readTrim(path.join(bodyDir, 'heartbeat.md'));
  57 + if (heartbeat) parts.push(heartbeat);
  58 +
  59 + if (agentId) {
  60 + try {
  61 + const goalContext = okrService.getActiveContextForAgent(agentId);
  62 + if (goalContext && goalContext.trim()) parts.push(goalContext.trim());
  63 + } catch (err) {
  64 + // eslint-disable-next-line no-console
  65 + console.error(`[IdentityContext] ⚠️ 读取目标上下文失败: ${err.message}`);
  66 + }
  67 +
  68 + // user.md / memory.md / 近期记忆已经由 PromptBuilder.buildCompanyStateParts 读过,
  69 + // 不在这里重复;hippocampus 检索原实现里跟那三样同一批读,但 PromptBuilder 没有
  70 + // 对应覆盖,仍然缺,补上。
  71 + const hippocampusPlans = retrieveRelevantPlans(agentId, searchText);
  72 + if (hippocampusPlans) parts.push(hippocampusPlans);
  73 + }
  74 +
  75 + const mainSkill = readTrim(path.join(bodyDir, '.claude/skills/main_skill/SKILL.md'));
  76 + if (mainSkill) parts.push('---\n\n# 我的核心主技能 (执行准则)\n\n' + mainSkill);
  77 +
  78 + const localLessons = readTrim(path.join(bodyDir, 'workspace/_lessons.md'));
  79 + if (localLessons) {
  80 + parts.push('---\n\n## ⚠️ 本地试错防坑经验 (_lessons.md)\n不要重复踩下面的坑,仔细阅读并绕开:\n\n' + localLessons);
  81 + }
  82 +
  83 + const skillsMd = readTrim(path.join(bodyDir, '.claude/skills/skills.md'));
  84 + if (skillsMd) parts.push(skillsMd);
  85 +
  86 + // 扫描除 main_skill 外的所有 skill 子目录
  87 + const skillsDir = path.join(bodyDir, '.claude', 'skills');
  88 + if (fs.existsSync(skillsDir)) {
  89 + for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
  90 + if (!entry.isDirectory() || entry.name === 'main_skill') continue;
  91 + const c = readTrim(path.join(skillsDir, entry.name, 'SKILL.md'));
  92 + if (c) parts.push(`---\n\n## 依赖技能: ${entry.name}\n\n${c}`);
  93 + }
  94 + }
  95 +
  96 + return parts;
  97 +}
  98 +
  99 +module.exports = { buildIdentityContextParts };
... ...
server/src/core/prompt-builder.js
... ... @@ -5,6 +5,7 @@ const fs = require(&#39;fs&#39;);
5 5 const path = require('path');
6 6 const { companyRootDir } = require('../../services/company-paths');
7 7 const { loadRecentMemory } = require('../../services/memory-service');
  8 +const { buildIdentityContextParts } = require('./identity-context');
8 9  
9 10 /**
10 11 * 交付物的唯一归宿:公司共享 workspace(28 §4.4)。
... ... @@ -287,6 +288,16 @@ class PromptBuilder {
287 288 // 与 getAgentPrompt / buildCompanyStateParts 是同一条理由。protocol 才是仓库里的静态文件。
288 289 parts.push(fs.readFileSync(agent.companyContextPath, 'utf-8'));
289 290 }
  291 + // 「本体装配静默坑」修复:soul.md/identity.md/tools.md/heartbeat.md/OKR 目标/
  292 + // hippocampus 检索/skills——agent-chat.js 一直在读,这条链路此前从未读过(见
  293 + // identity-context.js 头注)。只对非 SKILL.md 模式补,因为 isSkillBased 的 agent
  294 + // 是「借住在别人本体目录下的一个 scoped skill」,不是一个完整身份,硬套 soul/identity
  295 + // 语义不通——它连 companyDir 都恒为 null(agent-manager.js 的 scoped-skill 分支)。
  296 + parts.push(...buildIdentityContextParts({
  297 + bodyDir: agent.bodyDir,
  298 + agentId: agent.agentId,
  299 + searchText: userTask,
  300 + }));
290 301 }
291 302  
292 303 // 公司态 + 运行时坐标:**两条分支都要装**。SKILL.md 模式跳过的是 protocol 和
... ...
server/tests/unit/describe-role-definition-truncation.test.js 0 → 100644
  1 +/**
  2 + * Unit Test — describeRoleDefinition 截断可见性(本体装配静默坑修复)
  3 + *
  4 + * 原实现把 agent.md 悄悄切到前 2000 字:职能说明如果排在文件后半(本体写得长一点
  5 + * 就很常见),workgraph-planning 拿到的角色定义里根本没有这句话,而它不会知道
  6 + * 自己看到的是被切过的半截——「硬约束不前置就等于没写」。
  7 + *
  8 + * 这里锁的是:截断了就必须带⚠️提示 + 完整路径;没截断(原文本来就短)就什么都不多说。
  9 + * 用真实 system/agents/ 目录起一个 zz- 前缀的临时本体(同款手法见
  10 + * base-graduation-gate.test.js 的 zz-hire-test-co),跑完删掉,不留痕迹——
  11 + * locator.bodyDir() 没有 env 覆盖点,读的就是真实仓库路径,测试只能顺着这条路走。
  12 + */
  13 +import { describe, it, expect, afterEach } from 'vitest';
  14 +import fs from 'fs';
  15 +import path from 'path';
  16 +
  17 +const REPO_ROOT = path.resolve(__dirname, '../../..');
  18 +const AGENTS_ROOT = path.join(REPO_ROOT, 'system', 'agents');
  19 +
  20 +describe('describeRoleDefinition 截断可见性', () => {
  21 + const testAgentId = 'zz-test-role-trunc';
  22 + const testAgentDir = path.join(AGENTS_ROOT, testAgentId);
  23 +
  24 + afterEach(() => {
  25 + fs.rmSync(testAgentDir, { recursive: true, force: true });
  26 + });
  27 +
  28 + it('agent.md 超过 2000 字时,截断段落带⚠️提示 + 完整路径', () => {
  29 + fs.mkdirSync(testAgentDir, { recursive: true });
  30 + const longContent = `# ${testAgentId}\n` + 'x'.repeat(2500);
  31 + fs.writeFileSync(path.join(testAgentDir, 'agent.md'), longContent);
  32 +
  33 + delete require.cache[require.resolve('../../services/graph-engine.js')];
  34 + const engine = require('../../services/graph-engine.js');
  35 + const result = engine.describeRoleDefinition({ systemAgentId: testAgentId, companyId: null });
  36 +
  37 + expect(result).toContain(`⚠️ 原文共 ${longContent.length} 字,已截断到前 2000 字`);
  38 + expect(result).toContain(path.join(testAgentDir, 'agent.md'));
  39 + // 截断本身没变——2000 字上限仍是故意的,摘录长度不应该被这次修复顺带改掉
  40 + const excerpt = result.split(':\n')[1].split('\n(⚠️')[0];
  41 + expect(excerpt.length).toBe(2000);
  42 + });
  43 +
  44 + it('agent.md 不超过 2000 字时,不带任何截断提示', () => {
  45 + fs.mkdirSync(testAgentDir, { recursive: true });
  46 + const shortContent = `# ${testAgentId}\n短本体`;
  47 + fs.writeFileSync(path.join(testAgentDir, 'agent.md'), shortContent);
  48 +
  49 + delete require.cache[require.resolve('../../services/graph-engine.js')];
  50 + const engine = require('../../services/graph-engine.js');
  51 + const result = engine.describeRoleDefinition({ systemAgentId: testAgentId, companyId: null });
  52 +
  53 + expect(result).not.toContain('⚠️');
  54 + expect(result).toContain('短本体');
  55 + });
  56 +
  57 + it('本体不存在时回落到明确的「未找到」提示,不是截断逻辑的边缘情况', () => {
  58 + delete require.cache[require.resolve('../../services/graph-engine.js')];
  59 + const engine = require('../../services/graph-engine.js');
  60 + const result = engine.describeRoleDefinition({ systemAgentId: 'zz-does-not-exist-role-trunc', companyId: null });
  61 + expect(result).toContain('未找到该本体的 agent.md');
  62 + });
  63 +});
... ...
server/tests/unit/identity-context.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — identity-context.js(本体装配静默坑修复)
  3 + *
  4 + * 锁的是「work 节点只有 agent.md,soul/identity/tools/heartbeat/OKR/hippocampus/skills
  5 + * 全部缺失」这条修复的具体行为:读了哪些文件、按什么条件跳过、不重复已经被
  6 + * PromptBuilder.buildCompanyStateParts 读过的那三样(user.md/memory.md/近期记忆)。
  7 + *
  8 + * OkrService / retrieveRelevantPlans 两个依赖没有走 vi.mock——本仓库服务层是纯
  9 + * CommonJS(require/module.exports),vi.mock 面向的是 Vite 处理过的 ESM 模块图,
  10 + * 对 identity-context.js 内部自己 require() 出来的这条链路不生效(实测:mock 完全不拦截,
  11 + * `okrMod.OkrService` 收到的还是真实的类,不是 vi.fn())。改用更直接的手法——CJS 的
  12 + * `require()` 在同一进程内对同一路径返回**同一个** `module.exports` 对象,所以直接在
  13 + * `beforeEach` 里把这两个依赖模块的导出属性**原地换成假实现**,`identity-context.js`
  14 + * 用 `require()` 拿到的就是换过的那份;`afterEach` 换回来,不留痕迹。
  15 + * 这两个依赖也没有任何 env 覆盖点能让它们落到隔离的临时目录(不像 GRAPH_DB_PATH 那套),
  16 + * 真去读会既不确定又污染仓库数据,所以只验证「调用了、传参对不对、返回值有没有被
  17 + * 正确拼进去」,不验证它们内部的真实检索逻辑(那是各自模块自己的测试该管的事)。
  18 + */
  19 +import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  20 +import fs from 'fs';
  21 +import os from 'os';
  22 +import path from 'path';
  23 +
  24 +const okrModule = require('../../services/okr-service');
  25 +const memoryModule = require('../../services/memory-service');
  26 +const originalOkrService = okrModule.OkrService;
  27 +const originalRetrieveRelevantPlans = memoryModule.retrieveRelevantPlans;
  28 +
  29 +describe('identity-context.buildIdentityContextParts', () => {
  30 + let bodyDir;
  31 + let buildIdentityContextParts;
  32 + let okrCalls;
  33 + let hippocampusCalls;
  34 +
  35 + beforeEach(() => {
  36 + bodyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'identity-ctx-body-'));
  37 +
  38 + okrCalls = [];
  39 + hippocampusCalls = [];
  40 + // 原地换成假实现:identity-context.js 的 require('../../services/okr-service')
  41 + // 拿到的是同一个 module.exports 对象,属性换了它就用的是假的。
  42 + okrModule.OkrService = function FakeOkrService() {
  43 + return {
  44 + getActiveContextForAgent(agentId) {
  45 + okrCalls.push(agentId);
  46 + return agentId === 'has-goal' ? '## 🎯 当前参与的目标\n\n模拟目标文本' : '';
  47 + },
  48 + };
  49 + };
  50 + memoryModule.retrieveRelevantPlans = (agentId, searchText) => {
  51 + hippocampusCalls.push([agentId, searchText]);
  52 + return agentId === 'has-goal' ? `模拟检索结果(搜索词:${searchText})` : '';
  53 + };
  54 +
  55 + delete require.cache[require.resolve('../../src/core/identity-context.js')];
  56 + ({ buildIdentityContextParts } = require('../../src/core/identity-context.js'));
  57 + });
  58 +
  59 + afterEach(() => {
  60 + okrModule.OkrService = originalOkrService;
  61 + memoryModule.retrieveRelevantPlans = originalRetrieveRelevantPlans;
  62 + delete require.cache[require.resolve('../../src/core/identity-context.js')];
  63 + fs.rmSync(bodyDir, { recursive: true, force: true });
  64 + });
  65 +
  66 + function write(rel, content) {
  67 + const fp = path.join(bodyDir, rel);
  68 + fs.mkdirSync(path.dirname(fp), { recursive: true });
  69 + fs.writeFileSync(fp, content);
  70 + }
  71 +
  72 + it('bodyDir 为空时返回空数组,不抛错', () => {
  73 + expect(buildIdentityContextParts({ bodyDir: null })).toEqual([]);
  74 + });
  75 +
  76 + it('soul.md/identity.md/tools.md 都写了时按顺序读出,缺的那个跳过', () => {
  77 + write('soul.md', 'MY SOUL');
  78 + write('tools.md', 'MY TOOLS'); // identity.md 故意不写
  79 + const parts = buildIdentityContextParts({ bodyDir });
  80 + expect(parts).toContain('MY SOUL');
  81 + expect(parts).toContain('MY TOOLS');
  82 + expect(parts.join('\n')).not.toContain('identity.md');
  83 + });
  84 +
  85 + it('heartbeat.md 存在时读入,不存在时跳过(不报错)', () => {
  86 + expect(buildIdentityContextParts({ bodyDir })).not.toContain(expect.stringContaining('HEARTBEAT'));
  87 + write('heartbeat.md', 'HEARTBEAT CONTENT');
  88 + expect(buildIdentityContextParts({ bodyDir })).toContain('HEARTBEAT CONTENT');
  89 + });
  90 +
  91 + it('没有 agentId 时不查 OKR / hippocampus(不是「查了但是空」,是根本不查)', () => {
  92 + buildIdentityContextParts({ bodyDir });
  93 + expect(okrCalls).toEqual([]);
  94 + expect(hippocampusCalls).toEqual([]);
  95 + });
  96 +
  97 + it('有 agentId 时查 OKR 与 hippocampus,结果非空才拼进去', () => {
  98 + const parts = buildIdentityContextParts({ bodyDir, agentId: 'has-goal', searchText: '写一份 PRD' });
  99 + expect(parts.some((p) => p.includes('模拟目标文本'))).toBe(true);
  100 + expect(parts.some((p) => p.includes('模拟检索结果'))).toBe(true);
  101 + expect(hippocampusCalls).toContainEqual(['has-goal', '写一份 PRD']);
  102 +
  103 + // agentId 存在但两个依赖都返回空字符串——不拼空段落
  104 + const empty = buildIdentityContextParts({ bodyDir, agentId: 'no-goal', searchText: 'x' });
  105 + expect(empty.some((p) => p.includes('目标') || p.includes('检索'))).toBe(false);
  106 + });
  107 +
  108 + it('OKR 查询抛错时不影响其它内容,只是那一段缺失(不整体失败)', () => {
  109 + okrModule.OkrService = function ThrowingOkrService() {
  110 + return { getActiveContextForAgent: () => { throw new Error('boom'); } };
  111 + };
  112 + delete require.cache[require.resolve('../../src/core/identity-context.js')];
  113 + ({ buildIdentityContextParts } = require('../../src/core/identity-context.js'));
  114 +
  115 + write('soul.md', 'STILL HERE');
  116 + const parts = buildIdentityContextParts({ bodyDir, agentId: 'whoever' });
  117 + expect(parts).toContain('STILL HERE');
  118 + });
  119 +
  120 + it('main_skill/SKILL.md 存在时带专属标题拼入', () => {
  121 + write('.claude/skills/main_skill/SKILL.md', 'MAIN SKILL BODY');
  122 + const parts = buildIdentityContextParts({ bodyDir });
  123 + expect(parts.some((p) => p.includes('我的核心主技能') && p.includes('MAIN SKILL BODY'))).toBe(true);
  124 + });
  125 +
  126 + it('workspace/_lessons.md 存在时带警示标题拼入', () => {
  127 + write('workspace/_lessons.md', '别再犯这个错');
  128 + const parts = buildIdentityContextParts({ bodyDir });
  129 + expect(parts.some((p) => p.includes('本地试错防坑经验') && p.includes('别再犯这个错'))).toBe(true);
  130 + });
  131 +
  132 + it('skills.md 与其它 skill 子目录(排除 main_skill)都被扫到', () => {
  133 + write('.claude/skills/skills.md', 'SKILLS INDEX');
  134 + write('.claude/skills/main_skill/SKILL.md', 'MAIN'); // 不应该被当成「依赖技能」再扫一次
  135 + write('.claude/skills/foo-skill/SKILL.md', 'FOO SKILL BODY');
  136 + write('.claude/skills/bar-skill/SKILL.md', 'BAR SKILL BODY');
  137 + const parts = buildIdentityContextParts({ bodyDir });
  138 + expect(parts).toContain('SKILLS INDEX');
  139 + expect(parts.some((p) => p.includes('依赖技能: foo-skill') && p.includes('FOO SKILL BODY'))).toBe(true);
  140 + expect(parts.some((p) => p.includes('依赖技能: bar-skill') && p.includes('BAR SKILL BODY'))).toBe(true);
  141 + // main_skill 不能又被当成一个「依赖技能」重复拼一次
  142 + expect(parts.filter((p) => p.includes('MAIN')).length).toBe(1);
  143 + });
  144 +
  145 + it('.claude/skills 目录本身不存在时不报错,其它内容照常返回', () => {
  146 + write('soul.md', 'OK');
  147 + expect(() => buildIdentityContextParts({ bodyDir })).not.toThrow();
  148 + expect(buildIdentityContextParts({ bodyDir })).toContain('OK');
  149 + });
  150 +});
... ...