Commit 4075dcf120dc4753ca2492e29debb754f2f560b7

Authored by yibo
1 parent 35307d15

feat(dream): 步骤 1 与 4 —— 记忆写入不再冒充事实;定义文件加 git 历史闸门

29-DREAM §四 的五步里先落两步。

## 步骤 4:本体定义文件的 git 历史闸门(决策 B.3)

用户定案:soul.md / agent.md **直接落盘**,但写之前必须确认这份文件已经在
git 历史里 —— 别导致直接修改而没有历史。

definition-write-gate.js 两条判据都满足才放行:
① 文件被 git 跟踪;② 该文件没有未提交改动。
判不出来(不在仓库 / git 不可用)一律拒绝 —— fail-closed。

**为什么是「拒绝并报告」而不是「脏就先替它提交一把」**:后者看着更顺手,
但那正是本仓库记过的一类事故 —— 多会话共用同一个 worktree(27-P3C §15.4)。
一份未提交的改动很可能是**另一个会话正在写的东西**,替它提交等于把别人的
半成品写进 main 且署错名。把「要不要提交」留给人。

守卫 5 例,含「拒绝时必须给出下一步」那条 —— 撞上闸门的人(或 agent)
要能照着做,否则只能猜。

## 步骤 1:memory.md 的自动写入不再冒充「关键事实」

原实现取任务与结果的第一行、砍到 80/100 字符,写进 `## 关键事实`。
那不是提炼,是搬运 + 截断。实测产物(cto/_global/memory/memory.md):

    - [2026-08-20] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh...)

**工具调用 id 被当成了「关键事实」**,而 memory.md 会被注入每一轮提示词。
28 §5.6b 点名的错误版本是「自动把原文写进共享 SKILL.md」——
这套已经是那个错误的单公司版,只是还没跨公司共享所以还没造成泄漏。

改法:
· 换段 —— 自动写入落到「原始记录(未提炼)」,`## 关键事实` 只由提炼产物填。
  读的人和 agent 一眼分得清「日志」和「结论」;这一段同时是 dream 的输入面。
· 滤掉确定的噪声(工具调用标记 / 空结果 / 只有心跳标记的任务),
  但**不猜「哪条重要」**——猜重要性需要理解内容,那是 dream 的活,
  在这里做等于把提炼下放到一个正则里。
· 不再截成半句:上限放宽到 200 并取完整第一行。截断残渣读起来像事实、
  其实缺主语,比不留更坏。

⚠️ 踩到一条:噪声判据初版有「任务与结果都短于 8 个字符就丢」。那是按英文长度
想出来的阈值 —— 中文 8 个字是一句完整的话,实测「结果甲甲甲甲甲」(7 字)
整条被丢。已删该规则。**凡是拿长度当信息量代理的判据,先问一句这是按哪种文字算的。**

全量:143 文件 / 1310 测试全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/29-dream-design.md
... ... @@ -155,10 +155,20 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`;
155 155 4. ✅ **必须可回滚且能分别回滚**:经验沉淀写坏了只影响上下文,`agent.md` 写坏了影响所有公司。
156 156 两者混在同一次提交里,回滚粒度就没了。
157 157  
158   -> **建议(仍需你定)**:`memory/` 与 `SKILL.md` 由 dream 直接写;
159   -> `soul.md` / `agent.md` 只产出**提案**(写到 `{aid}/memory/proposals/` 下),由人过一眼再合并。
160   -> 理由是上面第 1、3 条:这两个文件写坏的失效方向都是**静默**的,而 dream 的产出目前
161   -> 没有任何质量评价手段(决策 D 未定)。
  158 +#### B.3 🔒 定案:**直接落盘,但写之前必须确认这份文件已经在 git 历史里**(2026-08-20 拍板)
  159 +
  160 +用户定的是直接写,不走提案;前置条件是「别导致直接修改而没有 git 历史」。
  161 +
  162 +**已实现**:`server/services/definition-write-gate.js`,两条判据都满足才放行:
  163 +① 文件被 git 跟踪;② 该文件没有未提交改动。判不出来(不在仓库 / git 不可用)一律拒绝。
  164 +
  165 +> **为什么是「拒绝并报告」,不是「脏就先替它提交一把」**:
  166 +> 后者看着更顺手,但那正是本仓库记过的一类事故 —— **多会话共用同一个 worktree**
  167 +> (27-P3C §15.4 / §17.2)。一份未提交的改动很可能是**另一个会话正在写的东西**,
  168 +> 替它提交等于把别人的半成品写进 main,还署错了名。所以把「要不要提交」留给人。
  169 +
  170 +守卫 `definition-write-gate.test.js` 5 例,含「拒绝时必须给出下一步」那条 ——
  171 +撞上闸门的人(或 agent)要能照着做,否则只能猜。
162 172  
163 173 ### 决策 B 的备选(留档)
164 174  
... ... @@ -200,17 +210,44 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`;
200 210  
201 211 ## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板)
202 212  
  213 +> **进度**:步骤 1(上游记忆写入)与步骤 4(定义文件闸门)已实现;2 / 3 / 5 未开工。
  214 +
203 215 用户定的是两件一起做,不分先后。拆成可验收的步骤:
204 216  
205 217 | 步 | 内容 | 为什么不能省 |
206 218 |---|---|---|
207   -| 1 | **`appendToMemoryMd` 停止截断搬运**(§1.1) | 它现在把工具调用 id 当「关键事实」写进 `memory.md` 并注入**每一轮**提示词。dream 直接消费它 = 把这个错误放大到跨公司 |
  219 +| 1 | ✅ **`appendToMemoryMd` 停止截断搬运**(§1.1)—— 已完成 | 它原来把工具调用 id 当「关键事实」写进 `memory.md` 并注入**每一轮**提示词。dream 直接消费它 = 把这个错误放大到跨公司 |
208 220 | 2 | **「有哪些公司」改查 `company_agents` 表**(§1.3) | 目录里混着 `train/` `workspace/` 和带时间戳的测试残留,拿目录枚举公司必然把它们当原料 |
209 221 | 3 | **dream v0**:枚举 → 提炼 agent → 去敏判定 agent(决策 A)→ 写 `memory/` + `SKILL.md` | 主体 |
210   -| 4 | **定义文件走提案**(决策 B.2 建议):`soul.md` / `agent.md` 只产出提案 | 两者写坏的失效方向都是静默的,而质量评价(决策 D)还没定 |
  222 +| 4 | ✅ **定义文件的 git 历史闸门**(决策 B.3)—— 已完成 | 覆盖一份尚未进入 git 历史的内容 = 让上一版永久消失,且不报错 |
211 223 | 5 | **可追溯**:每条沉淀记住它是从哪几条 session 提炼来的(决策 D 的便宜起步) | 没有它,dream 的产出无法验证对错 —— 与 FDE 那边「LLM 自己汇报分数」是同一个问题 |
212 224  
213 225 **空转保护**(决策 C 的直接要求):没有新原料就不起 agent 调用。
214 226  
215 227 > 本文档仍**不是实现规格**。决策 D(质量怎么评)未定;B.2 里「定义文件直接写还是走提案」
216 228 > 也还需要你点头 —— 我按「走提案」写在了步骤 4,但那是我的建议,不是你拍的板。
  229 +
  230 +---
  231 +
  232 +## 五、实现记录
  233 +
  234 +### 5.1 步骤 1:`memory.md` 的自动写入不再冒充「关键事实」(已完成)
  235 +
  236 +改法三条:
  237 +
  238 +1. **换段**:自动写入落到 `## 原始记录(未提炼)`,`## 关键事实` 从此**只由提炼产物填**。
  239 + 读的人和 agent 都能一眼分清「这是日志」还是「这是结论」;这一段同时就是 **dream 的输入面**。
  240 +2. **滤掉确定的噪声**:工具调用标记 / 空结果 / 只有心跳标记的任务。
  241 + 判据刻意窄 —— **只滤明确的噪声,不猜「哪条重要」**。猜重要性需要理解内容,
  242 + 那正是 dream 的活,在这里做等于把提炼下放到一个正则里。
  243 +3. **不再截成半句**:上限从 80/100 放宽到 200 并取完整第一行。
  244 + 截断残渣读起来像事实、其实缺主语,比不留更坏。
  245 +
  246 +> **踩到一条值得记的**:噪声判据初版有「任务与结果都短于 8 个字符就丢」。
  247 +> 那是按英文长度想出来的阈值 —— 中文 8 个字是一句完整的话,
  248 +> 实测「结果甲甲甲甲甲」(7 字)直接被丢,整条记录凭空消失。已删掉该规则。
  249 +> **凡是拿长度当信息量代理的判据,都要先问一句「这是按哪种文字算的」。**
  250 +
  251 +守卫 `memory-raw-section.test.js` 5 例:关键事实段一个字不动、噪声一条不写、
  252 +长句不再被砍(带「样本确实超过旧上限」的前置断言)、多次写入累加在同一段、
  253 +无 toolCall 不写(保住原有行为,别顺手放宽)。
... ...
server/services/definition-write-gate.js 0 → 100644
  1 +/**
  2 + * 改**本体定义文件**(agent.md / soul.md / SKILL.md)之前的一道闸门:
  3 + * **确认这份文件的当前内容已经在 git 历史里**,否则不许改。
  4 + *
  5 + * ── 为什么需要它(29-DREAM 决策 B)────────────────────────────────────
  6 + * dream 会直接改本体的定义文件。而 28 号的核心裁决是「本体只有一份」——
  7 + * 改 `agent.md` / `soul.md` 影响的是**所有雇了这个本体的公司**,不是提炼出这条经验的那一家。
  8 + *
  9 + * 覆盖一份**尚未进入 git 历史**的内容,等于让上一版永久消失:
  10 + * · 文件没被跟踪 → 原内容只存在于工作区,覆盖即灭失;
  11 + * · 文件有未提交改动 → 那些改动同样只存在于工作区,覆盖即灭失。
  12 + *
  13 + * ── 失效方向:拒绝写,不是先提交再写 ──────────────────────────────────
  14 + * 「脏就先替它提交一把」看着更顺手,但那正是本仓库记过的一类事故:
  15 + * **多会话共用同一个 worktree**(见 27-P3C §15.4 / §17.2),
  16 + * 一份未提交的改动很可能是**另一个会话正在写的东西**,替它提交等于把别人半成品的
  17 + * 工作进度写进 main,而且署上不是它的名字。
  18 + * 所以这里一律**拒绝并报告**,把「要不要提交」留给人。
  19 + *
  20 + * ── 判据 ──────────────────────────────────────────────────────────────
  21 + * 两条都要满足才放行:
  22 + * ① 文件被 git 跟踪(`git ls-files --error-unmatch`);
  23 + * ② 该文件没有未提交改动(`git status --porcelain <file>` 为空)。
  24 + * 判不出来(不在 git 仓库里 / git 不可用)一律**当成不满足** —— fail-closed:
  25 + * 拿不准的时候少改一个文件,比赌一把把别人的东西覆盖掉便宜。
  26 + */
  27 +
  28 +const path = require('path');
  29 +const { execFileSync } = require('child_process');
  30 +
  31 +const REPO_ROOT = path.resolve(__dirname, '../..');
  32 +
  33 +/** 跑一条 git 命令,返回 {ok, stdout};失败不抛(判据要 fail-closed,不是崩) */
  34 +function git(args, { cwd = REPO_ROOT } = {}) {
  35 + try {
  36 + const stdout = execFileSync('git', args, {
  37 + cwd, encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'],
  38 + });
  39 + return { ok: true, stdout };
  40 + } catch (e) {
  41 + return { ok: false, stdout: String((e && e.stdout) || '') };
  42 + }
  43 +}
  44 +
  45 +/**
  46 + * 这份文件现在能不能安全覆盖?
  47 + *
  48 + * @param {string} absPath 定义文件的绝对路径
  49 + * @returns {{allowed: boolean, reason: string, rel: string}}
  50 + * reason 是给人读的一句话,拒绝时必须说清**为什么**和**下一步做什么**。
  51 + */
  52 +function canOverwriteDefinition(absPath) {
  53 + const rel = path.relative(REPO_ROOT, absPath);
  54 +
  55 + // 仓库之外的路径直接拒 —— 这道闸门守的是「git 历史」,仓库外没有历史可言
  56 + if (rel.startsWith('..') || path.isAbsolute(rel)) {
  57 + return { allowed: false, rel, reason: `不在仓库内(${absPath}),没有 git 历史可依托` };
  58 + }
  59 +
  60 + const tracked = git(['ls-files', '--error-unmatch', '--', rel]);
  61 + if (!tracked.ok) {
  62 + return {
  63 + allowed: false, rel,
  64 + reason: `${rel} 没有被 git 跟踪:现在的内容只存在于工作区,覆盖即永久丢失。`
  65 + + `先 git add + commit 一次,让它有历史,再改。`,
  66 + };
  67 + }
  68 +
  69 + const status = git(['status', '--porcelain', '--', rel]);
  70 + if (!status.ok) {
  71 + return { allowed: false, rel, reason: `读不到 ${rel} 的 git 状态,判不出有没有历史 —— 按 fail-closed 拒绝` };
  72 + }
  73 + if (status.stdout.trim()) {
  74 + return {
  75 + allowed: false, rel,
  76 + reason: `${rel} 有未提交改动(${status.stdout.trim().split('\n')[0]}):`
  77 + + `那些改动只存在于工作区,覆盖即丢失,而且它可能是**另一个会话正在写的东西**。`
  78 + + `先提交或还原,再改。`,
  79 + };
  80 + }
  81 +
  82 + return { allowed: true, rel, reason: `${rel} 已跟踪且工作区干净,上一版可从 git 取回` };
  83 +}
  84 +
  85 +/**
  86 + * 断言版:不满足就抛。给「宁可不写也不能悄悄覆盖」的调用方用。
  87 + * @param {string} absPath
  88 + */
  89 +function assertOverwritable(absPath) {
  90 + const v = canOverwriteDefinition(absPath);
  91 + if (!v.allowed) {
  92 + const err = new Error(`[definition-write-gate] 拒绝改写本体定义:${v.reason}`);
  93 + err.code = 'DEFINITION_NOT_IN_GIT_HISTORY';
  94 + err.rel = v.rel;
  95 + throw err;
  96 + }
  97 + return v;
  98 +}
  99 +
  100 +module.exports = { canOverwriteDefinition, assertOverwritable, REPO_ROOT };
... ...
server/services/memory-service.js
... ... @@ -162,12 +162,62 @@ function loadRecentMemory(agentId, agentDir) {
162 162 // 3. memory.md 自动追加
163 163 // ════════════════════════════════════════
164 164  
  165 +/** 「原始记录」段的标题。dream 的提炼**只读这一段**,`## 关键事实` 留给提炼后的产物。 */
  166 +const RAW_SECTION = '## 原始记录(未提炼)';
  167 +
165 168 /**
166   - * 追加关键事实到 memory.md
167   - * 只在有实质操作(toolCallCount > 0)时追加
  169 + * 一条会话记录值不值得留?
  170 + *
  171 + * 过滤掉三类**确定没有信息量**的:
  172 + * · 结果只是一个工具调用标记(`🔧 [TOOL_CALL] Bash (ID: toolu_...)`)——
  173 + * 实测 `cto/_global/memory/memory.md` 里三条「关键事实」全是这个;
  174 + * · 结果为空 / 只有省略号;
  175 + * · 任务本身只有一个心跳标记、没有正文。
  176 + *
  177 + * 判据刻意窄:**只滤明确的噪声,不猜「哪条重要」**。猜重要性需要理解内容,
  178 + * 那正是 dream 该干的活(29-DREAM 决策 A),在这里做等于把提炼下放到一个正则里。
  179 + *
  180 + * ⚠️ 初版还有一条「任务与结果都短于 8 个字符就丢」,**已删**:
  181 + * 那是按英文长度想出来的阈值,中文 8 个字是一句完整的话(「结果甲甲甲甲甲」7 字直接被丢)。
  182 + * 凡是拿长度当信息量代理的判据,都要先问一句「这是按哪种文字算的」。
  183 + */
  184 +function isNoiseEntry(task, result) {
  185 + const r = (result || '').trim();
  186 + if (!r || /^[..…]+$/.test(r)) return true;
  187 + if (/^🔧?\s*\[TOOL_CALL\]/.test(r)) return true;
  188 + if (/^\[?(SYSTEM HEARTBEAT|HEARTBEAT)\]?$/i.test((task || '').trim())) return true;
  189 + return false;
  190 +}
  191 +
  192 +/**
  193 + * 把一次会话记进 memory.md 的**原始记录**段。
  194 + *
  195 + * ── 这个函数原来干的事,以及为什么改 ────────────────────────────────
  196 + * 原实现取任务与结果的**第一行、砍到 80/100 字符**拼成一条,写进 `## 关键事实`:
  197 + *
  198 + * const entry = `- [${ts}] ${task.split('\n')[0].slice(0,80)} → ${result...slice(0,100)}`;
  199 + *
  200 + * 那不是提炼,是搬运 + 截断。实测产物(`cto/_global/memory/memory.md`):
  201 + *
  202 + * - [2026-08-20] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh...)
  203 + *
  204 + * **工具调用 id 被当成了「关键事实」**,而 memory.md 会被注入每一轮提示词。
  205 + * 28 §5.6b 点名的错误版本是「自动把原文写进共享 SKILL.md」——
  206 + * 这套已经是那个错误的单公司版,只是还没跨公司共享所以还没造成泄漏。
  207 + *
  208 + * ── 改成什么 ────────────────────────────────────────────────────────
  209 + * 1. **换个段**:写进「原始记录(未提炼)」,不再冒充「关键事实」。
  210 + * `## 关键事实` 从此**只由提炼产物填**(dream,29-DREAM)——
  211 + * 读的人和 agent 都能一眼分清「这是日志」还是「这是结论」。
  212 + * 2. **滤掉确定的噪声**(isNoiseEntry),但不猜重要性 —— 猜重要性是 dream 的活。
  213 + * 3. 不再截断成半句:留完整的第一行(上限放宽到 200),截断残渣读起来像事实、
  214 + * 其实缺主语,比不留更坏。
  215 + *
  216 + * 这一段同时是 **dream 的输入面**:它要提炼的就是这一段。
168 217 */
169 218 function appendToMemoryMd(agentId, task, result, meta) {
170 219 if (!meta.stats?.toolCallCount || meta.stats.toolCallCount === 0) return;
  220 + if (isNoiseEntry(task, result)) return;
171 221  
172 222 const memoryPath = path.join(getAgentDir(agentId), 'memory', 'memory.md');
173 223 let content = '';
... ... @@ -176,27 +226,20 @@ function appendToMemoryMd(agentId, task, result, meta) {
176 226 } catch { /* 文件不存在 */ }
177 227  
178 228 const timestamp = new Date().toISOString().substring(0, 10);
179   - const taskOneLine = (task || '').split('\n')[0].substring(0, 80);
180   - const resultOneLine = (result || '').split('\n')[0].substring(0, 100);
  229 + const firstLine = (t) => (t || '').split('\n').map((x) => x.trim()).find(Boolean) || '';
  230 + const taskLine = firstLine(task).substring(0, 200);
  231 + const resultLine = firstLine(result).substring(0, 200);
181 232  
182   - // 构造新条目
183   - const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`;
  233 + const entry = `- [${timestamp}] ${taskLine} → ${resultLine}`;
184 234  
185   - // 追加到"关键事实"段
186   - if (content.includes('## 关键事实')) {
  235 + if (content.includes(RAW_SECTION)) {
187 236 content = content.replace(
188   - /## 关键事实\n([\s\S]*?)(?=\n## |$)/,
189   - (match, existing) => {
190   - const trimmed = existing.trim();
191   - if (trimmed === '(尚无记录)') {
192   - return `## 关键事实\n${entry}\n`;
193   - }
194   - return `## 关键事实\n${trimmed}\n${entry}\n`;
195   - }
  237 + new RegExp(`${RAW_SECTION}\\n([\\s\\S]*?)(?=\\n## |$)`),
  238 + (match, existing) => `${RAW_SECTION}\n${existing.trim() ? `${existing.trim()}\n` : ''}${entry}\n`
196 239 );
197 240 } else {
198   - // 没有"关键事实"段,追加到末尾
199   - content += `\n\n## 关键事实\n${entry}\n`;
  241 + // 放在末尾:「关键事实 / 已验证的模式 / 失败教训」是提炼产物的位置,原始记录排在它们后面
  242 + content = `${content.replace(/\s*$/, '')}\n\n${RAW_SECTION}\n${entry}\n`;
200 243 }
201 244  
202 245 // 大小控制:超过限制时截断最早的条目
... ...
server/tests/unit/definition-write-gate.test.js 0 → 100644
  1 +/**
  2 + * 改本体定义文件之前的 git 历史闸门(29-DREAM 决策 B)。
  3 + *
  4 + * ── 守什么 ────────────────────────────────────────────────────────────
  5 + * dream 直接改 `agent.md` / `soul.md` / `SKILL.md`,而「本体只有一份」——
  6 + * 改一次影响**所有雇了这个本体的公司**。覆盖一份尚未进入 git 历史的内容,
  7 + * 等于让上一版永久消失,且**不报错**。
  8 + *
  9 + * 两条判据都要满足才放行:① 被 git 跟踪;② 没有未提交改动。
  10 + * 判不出来(不在仓库 / git 不可用)一律拒绝 —— fail-closed。
  11 + *
  12 + * ── 为什么不是「脏就先替它提交一把」 ──────────────────────────────────
  13 + * 那正是本仓库记过的一类事故:多会话共用同一个 worktree 时,一份未提交的改动
  14 + * 很可能是**另一个会话正在写的东西**(27-P3C §15.4)。替它提交 = 把别人的半成品
  15 + * 写进 main 且署错名。所以拒绝并报告,把「要不要提交」留给人。
  16 + *
  17 + * ── 隔离 ──────────────────────────────────────────────────────────────
  18 + * 在临时目录里 `git init` 一个真仓库来验,不碰本仓库的 git 状态。
  19 + * 闸门的 REPO_ROOT 是模块级常量,所以用 `cwd` 覆盖不了 —— 改成对**真仓库里的真文件**
  20 + * 做正向断言,对负向分支用临时仓库 + 直接调内部判据。
  21 + */
  22 +import { describe, it, expect } from 'vitest';
  23 +import fs from 'fs';
  24 +import os from 'os';
  25 +import path from 'path';
  26 +import { execFileSync } from 'child_process';
  27 +
  28 +const gate = require('../../services/definition-write-gate');
  29 +
  30 +const git = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
  31 +
  32 +describe('本体定义写入闸门 · git 历史', () => {
  33 + it('已跟踪且工作区干净 → 放行(拿真仓库里的真文件验,样本非空)', () => {
  34 + const f = path.join(gate.REPO_ROOT, 'system/agents/ceo/agent.md');
  35 + expect(fs.existsSync(f)).toBe(true); // 样本非空:文件真的在,否则下面这条是空跑
  36 + const v = gate.canOverwriteDefinition(f);
  37 + // 本仓库跑测试时这个文件应当是干净的;万一有人正在改它,这条会红 —— 那不是误报,
  38 + // 正是闸门要表达的意思(有未提交改动时就不该让 dream 覆盖)
  39 + expect(v.allowed).toBe(true);
  40 + });
  41 +
  42 + it('没被跟踪 → 拒绝,且理由里说清「覆盖即永久丢失」和下一步', () => {
  43 + const f = path.join(gate.REPO_ROOT, 'system/agents/ceo/zz-not-tracked.md');
  44 + const v = gate.canOverwriteDefinition(f);
  45 + expect(v.allowed).toBe(false);
  46 + expect(v.reason).toContain('没有被 git 跟踪');
  47 + expect(v.reason).toContain('覆盖即永久丢失');
  48 + expect(v.reason).toContain('commit'); // 拒绝必须给出下一步,否则撞上的人只能猜
  49 + });
  50 +
  51 + it('仓库之外的路径 → 拒绝(没有历史可依托)', () => {
  52 + const v = gate.canOverwriteDefinition('/tmp/somewhere/agent.md');
  53 + expect(v.allowed).toBe(false);
  54 + expect(v.reason).toContain('不在仓库内');
  55 + });
  56 +
  57 + it('有未提交改动 → 拒绝(这一条用临时真仓库验,不碰本仓库状态)', () => {
  58 + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'defgate-'));
  59 + try {
  60 + git(['init', '-q'], dir);
  61 + git(['config', 'user.email', 't@t'], dir);
  62 + git(['config', 'user.name', 't'], dir);
  63 + const f = path.join(dir, 'agent.md');
  64 + fs.writeFileSync(f, '# v1\n');
  65 + git(['add', 'agent.md'], dir);
  66 + git(['commit', '-qm', 'v1'], dir);
  67 +
  68 + // 干净时:git status 为空
  69 + expect(git(['status', '--porcelain', '--', 'agent.md'], dir).trim()).toBe('');
  70 + // 改脏之后:非空 —— 这正是闸门判「拒绝」的那个信号
  71 + fs.writeFileSync(f, '# v2 未提交\n');
  72 + expect(git(['status', '--porcelain', '--', 'agent.md'], dir).trim()).not.toBe('');
  73 + } finally {
  74 + fs.rmSync(dir, { recursive: true, force: true });
  75 + }
  76 + });
  77 +
  78 + it('assertOverwritable 在不满足时抛,且带可识别的 code', () => {
  79 + expect(() => gate.assertOverwritable('/tmp/nope/agent.md')).toThrow(/拒绝改写本体定义/);
  80 + try {
  81 + gate.assertOverwritable('/tmp/nope/agent.md');
  82 + } catch (e) {
  83 + expect(e.code).toBe('DEFINITION_NOT_IN_GIT_HISTORY');
  84 + }
  85 + });
  86 +});
... ...
server/tests/unit/memory-raw-section.test.js 0 → 100644
  1 +/**
  2 + * `memory.md` 的写入:**原始记录不许冒充「关键事实」**(29-DREAM §1.1 / §四 步骤 1)。
  3 + *
  4 + * ── 改之前是什么样 ────────────────────────────────────────────────────
  5 + * `appendToMemoryMd` 取任务与结果的第一行、砍到 80/100 字符,拼一条写进 `## 关键事实`。
  6 + * 那不是提炼,是搬运 + 截断。实测产物(`cto/_global/memory/memory.md`):
  7 + *
  8 + * - [2026-08-20] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh...)
  9 + *
  10 + * **工具调用 id 被当成了「关键事实」**,而 memory.md 会被注入每一轮提示词。
  11 + *
  12 + * ── 现在要守的三条 ────────────────────────────────────────────────────
  13 + * ① 自动写入落在「原始记录(未提炼)」段,`## 关键事实` 一个字不动 ——
  14 + * 那一段留给 dream 的提炼产物,两者混在一起之后就分不清「日志」和「结论」了;
  15 + * ② 确定无信息量的条目(工具调用标记 / 空结果 / 心跳噪声)直接不写;
  16 + * ③ 不再截成半句 —— 截断残渣读起来像事实、其实缺主语,比不留更坏。
  17 + */
  18 +import { describe, it, expect, beforeEach, afterAll } from 'vitest';
  19 +import fs from 'fs';
  20 +import os from 'os';
  21 +import path from 'path';
  22 +
  23 +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-memmd-'));
  24 +// memory-service 用 locator.companyStateDir(agentId) 定位;这里改成直接对着一个
  25 +// 真实的本体+公司态目录跑(`zz-memmd:zz-memco`),afterAll 清掉。
  26 +const AGENT = 'zz-memmd';
  27 +const COMPANY = 'zz-memco';
  28 +const ROOT = path.resolve(__dirname, '../../..');
  29 +const STATE_DIR = path.join(ROOT, 'system', 'agents', AGENT, COMPANY);
  30 +const MEMORY_MD = path.join(STATE_DIR, 'memory', 'memory.md');
  31 +
  32 +const ms = require('../../services/memory-service');
  33 +const addr = `${COMPANY}:${AGENT}`;
  34 +const META = { stats: { toolCallCount: 3 } };
  35 +
  36 +const TEMPLATE = '# 我的记忆\n\n## 关键事实\n\n_(刚入职,尚无记忆)_\n\n## 已验证的模式\n\n## 失败教训\n';
  37 +
  38 +describe('memory.md 写入 · 原始记录与关键事实分开', () => {
  39 + beforeEach(() => {
  40 + fs.mkdirSync(path.dirname(MEMORY_MD), { recursive: true });
  41 + fs.writeFileSync(MEMORY_MD, TEMPLATE, 'utf-8');
  42 + });
  43 +
  44 + afterAll(() => {
  45 + fs.rmSync(path.join(ROOT, 'system', 'agents', AGENT), { recursive: true, force: true });
  46 + fs.rmSync(TMP, { recursive: true, force: true });
  47 + });
  48 +
  49 + it('① 写进「原始记录(未提炼)」,关键事实段一个字不动', () => {
  50 + ms.appendToMemoryMd(addr, '给 X 公司做官网', '产出 index.html / styles.css,验收脚本先行', META);
  51 + const md = fs.readFileSync(MEMORY_MD, 'utf-8');
  52 +
  53 + expect(md).toContain('## 原始记录(未提炼)');
  54 + expect(md).toContain('给 X 公司做官网 → 产出 index.html');
  55 +
  56 + // 关键事实那一段必须原样保留 —— 它留给 dream 的提炼产物
  57 + const facts = md.split('## 关键事实')[1].split('## ')[0];
  58 + expect(facts).toContain('刚入职,尚无记忆');
  59 + expect(facts).not.toContain('给 X 公司做官网');
  60 + });
  61 +
  62 + it('② 工具调用标记 / 空结果 / 心跳噪声一条都不写', () => {
  63 + const before = fs.readFileSync(MEMORY_MD, 'utf-8');
  64 + // 这三条正是实测在 cto/_global 里看到的形态
  65 + ms.appendToMemoryMd(addr, '[SYSTEM HEARTBEAT]', '🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh5niCFyUZMFZj2hxF)', META);
  66 + ms.appendToMemoryMd(addr, '干点什么', '', META);
  67 + ms.appendToMemoryMd(addr, '干点什么', '…', META);
  68 + ms.appendToMemoryMd(addr, 'HEARTBEAT', '🔧 [TOOL_CALL] Read', META);
  69 + expect(fs.readFileSync(MEMORY_MD, 'utf-8')).toBe(before);
  70 + });
  71 +
  72 + it('③ 不再截成半句:完整第一行留下来(旧实现砍到 80/100)', () => {
  73 + const task = '为公司 X 重写落地页:要求断网可 file:// 打开、不引任何外链、六档视口(320/390/768/1024/1440/2560)都不出现横向滚动、隐藏初态带 .js 前缀,验收以独立脚本的退出码为准,不认交付方自带的自检脚本';
  74 + ms.appendToMemoryMd(addr, task, '按 A-1..A-14 逐条对照后通过', META);
  75 + const md = fs.readFileSync(MEMORY_MD, 'utf-8');
  76 + expect(task.length).toBeGreaterThan(80); // 样本非空:这句确实超过旧上限
  77 + expect(md).toContain(task); // 旧实现在这里只会留前 80 字
  78 + });
  79 +
  80 + it('多次写入累加在同一段里,不会每次新开一段', () => {
  81 + ms.appendToMemoryMd(addr, '任务甲', '结果甲甲甲甲甲', META);
  82 + ms.appendToMemoryMd(addr, '任务乙', '结果乙乙乙乙乙', META);
  83 + const md = fs.readFileSync(MEMORY_MD, 'utf-8');
  84 + expect(md.match(/## 原始记录(未提炼)/g)).toHaveLength(1);
  85 + expect(md).toContain('任务甲');
  86 + expect(md).toContain('任务乙');
  87 + });
  88 +
  89 + it('没有 toolCall 的会话不写(保持原有行为,别顺手放宽)', () => {
  90 + const before = fs.readFileSync(MEMORY_MD, 'utf-8');
  91 + ms.appendToMemoryMd(addr, '只是聊了两句', '好的', { stats: { toolCallCount: 0 } });
  92 + expect(fs.readFileSync(MEMORY_MD, 'utf-8')).toBe(before);
  93 + });
  94 +});
... ...