# AGENTS.md —— dsh-ace 仓库路由 本文件是 agent 进入本仓库的第一站:只做路由、命令与红线,细节一律下沉到对应文档。 ## 这是什么 dsh-ace 是 DSH 原生插件(cordis bundle),实现 ACE 方法:把操作手册(playbook)当作可进化资产,经 `ace_reflect`(复盘)与 `ace_curate`(增量并入)演化,`ace_playbook` 供查阅。手册默认在本机共享层 `/ace/playbook.md`(跨工作区共用一本);`playbookPath` 填相对路径则改为该工作区私有。 ## 文件地图 | 要了解 | 去读 | | --- | --- | | 安装、配置字段、首次使用 | README.md | | 架构、数据模型、不变量、红线依据 | DESIGN.md | | 测试怎么跑、覆盖了什么、还差什么 | TESTING.md | | 论文(arXiv:2510.04618)条款逐条对照:实现了哪些、换了什么、哪些没做 | PAPER-ALIGNMENT.md | | **只给调度者用的只读体检脚本**(不进插件运行时、不进设置页) | `tools/diagnose.mjs`,用法见 DESIGN.md「第 37 轮」小节 | ## 代码地图 | 文件 | 职责 | | --- | --- | | `lib/index.js` | 插件入口:Config、三个工具注册、系统提示段 | | `client.js` | 浏览器半边:在 DSH 设置面板注册「ACE 手册」配置页(settings.section slot,经 ctx.configForms 读写配置;固定 VCP 暗色 + JetBrains Maple Mono) | | `lib/playbook.js` | 手册持久化:解析/序列化/增量操作/原子写/互斥锁(同进程 `withLock` + 跨进程 `withFileLock`,组合入口 `withExclusiveLock`;跨进程锁**不抢占活着的持锁者**,超时失败而非覆盖)。**第 33 轮起解析层只管「活跃条目」**:`## 归档区` 章节彻底不认(归档搬进独立文件,见 `lib/archive-file.js`),meta 只剩四字段 created → updated → hits → lastHit(可选 `auto=true` 恒排最后),三个时间字段都是毫秒精度本地时区 ISO(字典序即时间序,旧日期格式照常解析);`netScore` / `bumpScores` / `helpful` / `harmful` 已整层删除。纯逻辑,可独立测试 | | `lib/merge.js` | 相似度层:BM25 对称比率(`MERGE_THRESHOLD_RATIO = 0.5` / `SUPPLEMENT_RATIO = 0.4`)与 **add 的冲突判定**(`detectAddConflicts`/`detectAllConflicts`);只负责「找配对与算相似度」,**不做合并执行**(执行要调 LLM,在 `lib/merge-llm.js`)。依赖方向单向:playbook.js ← merge.js ← commit.js ← index.js | | `lib/commit.js` | 提交编排:锁内「读最新 → 逐条冲突判定 → 应用安全部分 → 原子写」(`commitOps`);三条写入路径共用,也是并发用例跑的生产同款路径。**第 33 轮起多两个钩子**:`options.evict`(超 `maxEntries` 时按 `lib/evict.js` 的 `planEvictions` 挑人搬进归档)与 `options.merged`(合并搬移指令);顺序硬约束是**先 `appendArchivedEntries` 写归档文件、再 `writeFileAtomic` 写手册**——反过来一旦手册写失败,被淘汰/合并的条目会从两边一起消失,等于物理删除。另有 `commitCapacity`(0 候选回合的纯腾位路径,可带 `options.ops` 应用合并后的正文)与 `commitMeta`(只改 meta 的命中计数路径) | | `lib/trigger.js` | 自动复盘触发判定的纯函数:mutating 工具集合、主代理判定、触发条件判定,以及调度用的状态机(`recordTurnEnd`/`takePendingRecap`/`requeuePendingRecap`/`abandonPendingRecap`/`restorePendingMutCount`/`isBusyError`);纯逻辑,可独立测试。`recordTurnEnd` 挂待办时把**改之前**的回合号存进 `pending.prevLastRecapTurn`,并给待办打上**代次** `seq`(= `state.pendingSeq`,每挂一份新待办 +1);挂新待办时若**队列里还压着一份没执行过的旧待办**(上一回合挂了、空闲边沿撞忙放回来、冷却又还没到),它是**合并**不是覆盖:新待办的 `mutCount` = 旧待办 `mutCount` + 本回合计数(谁都不吞谁,否则旧待办那一整轮经验静默蒸发),`prevLastRecapTurn` 取**旧待办的**(= 最老那一笔的原值,否则还把回到旧待办那一轮就会白吃中间那几回合的冷却),`attempts` 从 0 重新计(旧待办被撞忙浪费的那次不算到新待办头上)并写一条 info 留痕(`recordTurnEnd` 因此有可选 `logger` 参数);旧待办 `seq` 被顶替后 `ownsPending` 天然拒掉迟到结算;`abandonPendingRecap` 用 `prevLastRecapTurn` 把「没材料跳过」吃掉的冷却与 mutating 计数原样还回去,`restorePendingMutCount` 在**真实失败**时只还 mutating 计数、不回滚冷却。两者共用内部私有判定 `ownsPending(st, pending)`:**只回滚自己取出的那一份**——按代次比对(`pending.seq === state.pendingSeq`),所以「`state.pending` 已换成新对象」和「新待办已被另一个空闲边沿取走、`state.pending` 又变回 null」两种情形都能拒绝,一个字段都不动并写 warn;没有 `seq` 的旧形状待办要过两档兜底:`state.pendingSeq > 0`(代次前进过,说明之后一定挂过更新的待办)时**一律不认**——否则队列恰好为空时迟到的旧回滚会把旧回合计数还回来、把冷却倒退;只有 `pendingSeq === 0` 的全新状态才退回「`state.pending` 压着别的对象就拒绝」的对象身份判定(生产路径 `recordTurnEnd` 必写 `seq`,这一档只由手工构造命中)。`requeuePendingRecap`(撞忙回队)同样复用 `ownsPending`:**放回不是结算**(不记进已结算账本、不改 `attempts`/`seq`,所以重试路径不会被误拒),但不归自己时一个字段都不动、返回 `rejected:true` 并写 warn——否则迟到的那份会把 await 窗口里新挂上的待办静默覆盖掉,两份待办的额度一起蒸发、而新待办那一轮的冷却已经白吃;耗尽分支同理,只清自己那一份。两条回滚路径还**共用同一本已结算账本**(`SETTLED_PENDINGS`,WeakSet 按对象身份记账):同一份待办被任一路径结算过后,另一条路径再结算一律返回 false、不改任何字段并写 warn——否则交叉调用会把同一批 mutating 计数还两遍(实测 3→6→9);`requeuePendingRecap` 不许记进这本账。第 7 轮(F1「在飞旧待办额度蒸发」)又补一层:`ownsPending` 判「不归我」时不再一律丢弃——**在飞**的旧待办(被 `takePendingRecap` 摘走、尚未结算)若计数没被 `recordTurnEnd` 合并进更新的待办,就由 `adoptOrphanPending` **过继**给当前状态(队列里那份新待办的 `mutCount` 上,且 `abandonPendingRecap` 这一路把 `prevLastRecapTurn` 退到两者**更早**者;队列已空则加回 `st.mutCount` 但**不动** `lastRecapTurn`,冷却留给那份在飞待办自己结算,避免退两遍),并写一条 warn 留痕。三本模块级 WeakSet 守幂等:`SETTLED_PENDINGS`(已结算,abandon/restore 共用)、`ABSORBED_PENDINGS`(已被合并吸收,**绝不许**再过继,否则同一批计数翻倍)、`ADOPTED_PENDINGS`(已过继一次,重复被拒不再搬)。`clampMutCount` 把负数/垃圾计数按 0 处理(H1);`logInfo`/`logWarn` 用 `typeof` + try/catch 兜住畸形 logger(H2,这些日志全在竞态回滚路径上,日志炸掉会把「额度还没还回去」变成「整个空闲边沿抛异常」) | | `lib/recap.js` | 自动复盘调度器(`createRecapController`)只做两件事:**① 回合边界取走命中 id 一次原子写盘**(`onTurnStopping` 里无条件 `takeHitIds` + `flushHits`,冷却回合、阈值未到的回合也取,否则 A 回合的注入会被记成 B 回合的结果;**写盘失败只 warn 不抛,但会借 `noteHitIds`(= `lib/ledger.js` 的 `note`)把这批命中原样写回账本,下一个回合边界连同新命中一起重试**——账本在取走时就清空了,不退回等于真丢;没有 `noteHitIds` 可退的调用方才退化为「丢弃 + 明说已丢失」);**② 空闲边沿取待办跑复盘**(`onStatus` 报 idle 时先 `await` 上一次命中落盘的 Promise 链,再 `takePendingRecap` → `whenIdle` → `runRecap`;撞宿主忙碌放回队列重试,上限 3 次;`runRecap` 返回 `{skipped:true}` 时把冷却与计数还回去、不推失败提示)。**第 33 轮整层删除**:`settling` / `settlingIds` / `usages` / `settlePendingUsage` / `hasPendingUsage` / `noteTurnUsage` / `pendingUsageIds` / `adoptUnobservedUsage` / `settleOrphanUsages` / `noteTool` / `noteError` 与三档回合信号——计分没了,这些时序约束就失去存在理由;跨进程锁与原子写一条都没动 | | `lib/trajectory.js` | 把宿主 `session.deriveMessages()` 的模型可见历史渲染成给复盘模型看的紧凑轨迹(**第 28 轮起整条跳过本插件自己投递的注入消息**——`source.kind==='dsh-ace' && source.form==='injection'` 在 `renderMessage` 入口就 return,避免 Reflector 把手册已有条目当成本轮用户说过的话复述一遍;过滤只认这两个字段,`form:'notice'` 等照常渲染;两个字符串字面量必须与 `lib/inject-delivery.js` 的 `INJECTION_SOURCE_KIND`/`_FORM` 一致,改动任一处要同步)(`renderTrajectory` 纯渲染 + `collectTurnTrajectory` 取用 + `buildRecapSummary` 拼 summary);`maxMessages` 只取尾部,`maxChars` 超了**首尾都保留**(开头 `min(maxHeadChars, maxChars/2)` 保证任务目标不丢 + 中间 `…(中间 N 字符已省略)` + 结尾吃剩余预算,总长 ≤ `maxChars`),tool 结果行带工具名(`结果(read): …`,靠同一次渲染内的 tool-call id 映射反查);`maxChars` 小到放不下尾部时退化成「开头 + 省略标记」(标记里的 N 恒等于真实丢弃数,连标记都放不下时返回空串);`buildRecapSummary` 保证**整条 summary**(含两行标题)也 ≤ `maxChars`(**唯一例外**:`maxChars` 小于计数行本身的长度时以保证计数行完整为准,返回整行、允许超出——生产路径不传 `maxChars`,线上不可达),畸形输入一律跳过不抛错。**零 import(连 node 内置都不 import)、不碰宿主包、不读盘**,可独立测试 | | `lib/notice.js` | 通知消息构造(`textBlocks`/`noticeMessage`):把文案包成宿主契约要求的 `ContentBlock[]`。**零 import、不读盘**(与 lib/trajectory.js 同一条红线)——宿主 `createUserMessage` 不校验 `content` 形状,裸字符串进了会话历史会让宿主 52 处 `.content.some` 抛 `TypeError`、整个会话永久坏死(I10;tests/notice.test.mjs 的 T226/T227/T228 守着) | | `lib/prompts.js` | **Reflector 提示词整份重写**(第 33 轮,四段式:目标 / 只做什么(条件写成「当……时」触发式且含用户真会说的词)/ 不做什么(含反例)/ 怎么写才能被召回(讲清 BM25 吃哪些词面)),输出契约(`candidateLine` 的 JSON 形状)保持不变;另有 `reflectorUserInput` 与 LLM JSON 提取 `extractJsonObject`。**Curator 提示词已删除**:候选是新增还是覆盖、覆盖哪一条,全部由 `lib/archive.js` 的确定性规则判。**唯一的模型合并提示词是 `MERGE_SYSTEM`,在 `lib/merge-llm.js`**(不在本文件) | | `lib/llm.js` | 模型路由解析与单次 LLM 调用 | | `lib/playbook-path.js` | 手册与归档文件位置解析(绝对路径 / `~` 展开 / 相对工作目录 / 空串=本机共享层 `/ace/playbook.md`)。**第 33 轮新增归档侧**:`ARCHIVE_FILE_NAME = 'archive.md'`、`resolveSharedArchiveFile`(`/ace/archive.md`)、`resolveArchiveFile`(与手册**同目录**:`path.dirname(resolvePlaybookFile(...)) + archive.md`),不写死绝对路径。纯逻辑,可独立测试 | | `lib/settings-path.js` | 设置文件位置解析(profileContext → DSH_PROFILE_DIR → DSH_HOME → ~/.dsh)与旧文件一次性迁移(纯逻辑,可独立测试) | | `lib/entries.js` | 手册条目清单(`GET /api/dsh-ace/entries` 的数据源):复用 `parsePlaybook` 把解析结果摊平成固定形状的记录,**活跃区与归档区分两段返回**(`entries` / `archived`;归档那段读的是独立归档文件 `archive.md`,条目额外带 `archive:{reason,date,supersededBy}`,只读展示、无删除入口);**只读**,不写盘不加锁(纯逻辑 + 只读 IO,可独立测试) | | `lib/archive.js` | 候选判定与归档搬移,**全纯函数**:`decideCandidates`(完全重复丢弃 / 近似比率 ≥ 0.5 命中某条 → 候选更短则「新增 + 取代旧的」、不更短则原地 update / 0.4–0.5(`SUPPLEMENT_RATIO`)走增量补充——只把旧条目还没有的那部分正文追加进 rationale,不新增、不覆盖旧正文、不占名额,候选没带新细节则 `dropped reason='supplement-redundant'`)、`resolveSupersedes`、`applyArchiveMoves`(**永不物理删除**)。**第 33 轮删掉**:`archiveReasonFor`(低分 / 衰减 / 过期三判据)、`scanArchives`、`pendingIdSet` 与 `ARCHIVE_LOW_SCORE` / `ARCHIVE_DECAYED_NET_FLOOR` / `ARCHIVE_STALE_DAYS` —— 没有净分就不需要出局线与复活,也没有「按时间扫活跃区」这条判据;腾位改由「满额 + 最少使用淘汰」确定性触发 | | `lib/evict.js` | **最少使用淘汰**的判据层(第 33 轮,纯函数、零 IO):`compareForEviction(a, b)` 排序 = `hits` 升序 → `compareStamps(created)` 更早先走 → `idNumber` 数字升序;`planEvictions(entries, need, {date, protectDays})` → `[{id, reason:'evicted', date}]`(第 36 轮加保护期:`created` 距今不足 `protectDays` 天的先归入受保护组,够 `need` 就只从不受保护组取,不够时用受保护组按同一全序补齐——只改先淘汰谁,位数一位不减)。**只看这三个稳定量,绝不看分数**(分数已不存在);`hits` 也**不进注入排序键**,否则会出现「命中多 → 排得更前 → 更容易再命中」的自增强棘轮 | | `lib/archive-file.js` | **归档独立文件**(第 33 轮):`initialArchiveHeader` / `parseArchiveFile` / `serializeArchiveFile` / `markArchived` / `readArchiveSnapshot`(ENOENT → 空归档,不造文件)/ `readOrInitArchive` / `appendArchivedEntries`(同 id 保留先到的,写入走 `withExclusiveLock` + `writeFileAtomic`)/ `maxArchivedIdNumber`(扫归档**原文的标题行**取最大编号:新格式 `### [ace-500] x`、空标题 `### [ace-888]`、旧格式 `### ace-500` 三种都认,正文里偶然出现的 `ace-数字` 不算)。格式与手册同构(同样的条目块 + 元数据行 + 一行 `- 归档:reason=… date=…`),人可直接读、可直接手改;**归档只在这里,永不出现在手册文件里,也永不进任何会话** | | `lib/merge-llm.js` | **LLM 合并**(第 33 轮):`MERGE_SYSTEM`(四段式 + 反例/正例)、`mergePairInput`、`parseMergeResult`(输出必须过 `extractJsonObject`,四字段任一不合格即判该对失败)、`findMergePairs`(BM25 对称比率配对,互不重叠)、`survivingEntry`(保留 `created` 更早那条的 id)、`planMerges`(最多 `MERGE_MAX_ROUNDS = 5` 轮,每轮只喂一对;某对失败就摘掉换下一对,**永不抛错**)。走 `lib/llm.js` 同一条模型路径,零新增依赖 | | `lib/timestamp.js` | **毫秒时间戳**(第 33 轮,**零 import**):`nowStamp` → `YYYY-MM-DDTHH:mm:ss.SSS±HH:MM`(本地时区偏移,字典序即时间序)、`dayOf` / `isStamp`(旧日期格式照常解析)、`compareStamps`(纯字符串比较,空串最小)。界面上仍只显示到日,排序用毫秒精度,同毫秒并列由 id 数字兜底 | | `lib/entry-format.js` | 条目文本格式的**唯一实现**:`HEADING_RE` / `ID_HIGHWATER_RE` / `ARCHIVE_LINE_RE`、`ARCHIVE_REASONS`(`superseded` / `removed-by-request` / `evicted` / `merged` 四原因)、`emptyMeta` / `parseMeta`(旧文件的 helpful/harmful 静默丢弃)/ `metaLine`(created → updated → hits → lastHit → auto)、`parseArchiveLine` / `formatArchiveLine`、`makeEntry`、`parseEntryDocument`(**不认 `## 归档区`**)、`writeEntryBlock`、`serializeEntryDocument`。手册与归档文件共用这一套 | | `lib/recap-pipeline.js` | 复盘管道(`runRecapPipeline`):读快照 → 多轮复盘 → 候选过滤 → 判定 → (超上限时)先合并再淘汰 → 落盘 + 归档 → merge-inbox。**命中账本不在这里取**(D4):取走与落盘都归回合边界(`lib/recap.js` 的 `takeHitIds` / `flushHits`),流水线既不取也不结算。**第 33 轮删掉** `scoreUsedIds` 与整条 `pendingIds` 排除链(计分没了,时序约束失去理由);**候选过滤只剩「标题字符数 > 0」**(空标题拒绝,不再有长度上限、指代词、文件名规则)。**零宿主 import**,可被测试直接真跑整链;判定 / 淘汰两处都不调 LLM,只有满额合并调 | | `lib/reflect.js` | 多轮复盘的收敛规则(`reflectRounds`,`REFLECT_MAX_ROUNDS=3`):某轮返回空数组即判定收敛,第一轮抛错上抛、后续轮抛错按已提炼候选落盘 | | `lib/inject.js` | 自动注入:token 估算(ASCII ×0.4 + 全角 ×1.0,向上取整)、排序与预算选择(第 36 轮:**排序键 = 0.9 × 归一化相关度 + 0.1 × 归一化话题热度**,热度经 `options.heat` 传入、由 `lib/heat.js` 算;同分按 id 数字升序;`hits` 高低不影响排序;**0 分条目仍然绝不进候选**——热度只能在有字面交集的候选之间重排;条数与预算读 `injectMaxEntries` / `injectTokenBudget`,常量仍作兜底默认)、渲染。**第 33 轮删掉**:0.85×相关度 + 0.15×净分饱和的加权键、出局线 `INJECT_NET_FLOOR`、考察位三常量(`PROBATION_RELATIVE_MIN` / `PROBATION_COOLDOWN_DAYS` / `PROBATION_MAX_PER_TURN`)与 `selectInjection` 的 `probation` 返回值 | | `lib/prompt-section.js` | 系统提示段文本(`playbookSectionText`)与 section 描述符:**只剩逐字常量**的机制说明(随开关与路径变化,绝不随运行状态变化),**回调绝不抛错**(宿主对 section 抛错的处理是静默终止整回合)。注入块与 inbox 计数句都已从这里搬走(第 28 轮,见 `lib/inject-delivery.js` 与 DESIGN.md「为什么注入必须追加进历史」) | | `lib/inject-delivery.js` | 注入**投递**:`agent/pre-step` waterfall 里 `await next()` → 只在主代理 + 有未投递批次时用宿主 `createUserMessage` 造一条 user 消息(`source:{kind:'dsh-ace', form:'injection'}`)并 `toSpliced` 到 `decision.messages` 末尾,spread 保留 `startsRequestSeries` 等字段;同一批只投递一次;任何异常只 warn + `injectedFailed + 1` + 原样返回 decision(pre-step 抛错会打哑整回合)。`createUserMessage` 由 `lib/index.js` 注入进来(本文件不 import 宿主包) | | `lib/ledger.js` / `lib/injection-flow.js` | 命中账本接缝:`publish` 预检索结果 → `agent/pre-step` 投递时 `take(agent)` **认领**尚未投递的那一批(`delivered` WeakMap:同一批第二次返回 `null`,所以同一回合后续每一步都不重复追加)→ 消息**真追加进历史之后**才 `note(agent, ids)` 记进账本(**只给主代理记**,`isMainAgent` 门;拆成 take/note 是为了不留下「账本有、历史没有」的幽灵注入)→ **回合边界 `takeHitIds` 取走**(`lib/recap.js`,取走即清空,冷却回合也取)→ 立即 `flushHits` 一次原子写盘。**`hits` 是唯一的淘汰依据**(`ace_playbook` 带 query/id 的定向检索也 +1,整本读取不计数) | | `lib/stats.js` | `GET /api/dsh-ace/stats` 的聚合纯函数(`summarizePlaybook`/`collectPlaybookStats`):手册规模/来源/14 天分布 + 本次会话的运行计数器(`runtime` **七键**:`injectedBatches` / `injectedAppended` / `retrievalHits`(带 query/id 的定向检索命中数)/ `evicted` / `merged` / `archived` / `archivedByReason`(四原因 `superseded` / `removed-by-request` / `evicted` / `merged`);第 33 轮删掉 `recaps` / `candidates` / `written` / `helpful` / `harmful` / `unobserved` / `gateRejected` / `injectedProbation`);只读,不写盘不加锁 | | `lib/catalog.js` | `GET /api/dsh-ace/catalog` 的组装纯逻辑(`buildCatalog`):逐个 provider 各自 try/catch + `withTimeout` 15s,坏掉的 provider 只让它自己 `models: []` 并附 `error`。**超时只放弃等待、不 abort 上游**——宿主 `LlmRuntime.listModels(provider)` 没有 `signal` 形参(注释见 `lib/catalog.js:10-24`),泄漏面有界(一次请求最多一条悬挂 promise),上游晚到的 reject 被吞掉 | | `tests/playbook.test.mjs` | 持久化层单元测试(node --test) | | `tests/concurrency.test.mjs` | 真实子进程并发用例(两个 node 子进程同时操作同一本手册,跑 lib/commit.js 的生产同款路径;脚本写在系统临时目录,用完即删) | | `tests/playbook-path.test.mjs` | 手册路径解析链的单元测试(node --test) | | `tests/settings-path.test.mjs` | 设置路径解析与迁移的单元测试(node --test) | | `tests/entries.test.mjs` | 条目清单的单元测试 + `GET /api/dsh-ace/entries` 的 HTTP 面契约(行为面跑分支调用的函数,源码级钉住围栏/405/降级) | | `tests/trajectory.test.mjs` | 轨迹渲染器的单元测试(渲染顺序/角色前缀、**注入消息整条跳过**(`source.kind==='dsh-ace' && form==='injection'`;去掉 source 后照常渲染、`form:'notice'` 等五种只命中一半条件的照常渲染、畸形 source 不误伤)、system·developer·reasoning 跳过、tool-call 与 [失败] 结果、image·file 占位、maxMessages 尾部截断、maxChars 首尾保留(含「任务目标仍在」与 maxChars=200 极端值)、tool 结果按 id 绑定工具名与跨调用不串味、退化分支的省略数等于真实丢弃数、整条 summary ≤ maxChars、畸形输入不抛错),外加源码级断言:零 import、`runRecap` 确实喂轨迹且轨迹为空不调 LLM(返回 `{skipped:true}`)、`listAgents`/`inject 'agents'` 无残留 | ## 命令 ``` npm test # node --test tests/ node --check lib/index.js # 语法检查(其余 js 同法) ``` ## 红线(改动前必读,完整依据见 DESIGN.md「非功能红线」) 1. 手册只能增量演化(增/改/删单条),不存在也不许加「整本重写」的代码路径或提示词; 2. 手册与归档的写入**只能**走 `withExclusiveLock`(= 同进程 `withLock` + 跨进程 `withFileLock`)+ `writeFileAtomic`,不许绕开直接 `fs.writeFile`。锁内顺序是硬约束:**先 `appendArchivedEntries` 写归档文件、再 `writeFileAtomic` 写手册**(`lib/commit.js`)——反过来一旦手册写失败,被淘汰/合并的条目会从两边一起消失,等于物理删除; 3. LLM 输出必须经 `extractJsonObject` 结构化校验后才能落盘,原始文本不直接进手册。满额合并的输出同样要过这一关,四条字段(title/condition/action/rationale)任一不合格即判**该对**失败; 4. 引用 DSH API 必须注明 checkout 出处(文件:行号),不许凭空捏造; 5. 不引入新的 npm 运行时依赖(宿主服务一律走 `inject` 声明;反过来说,**不再用到的服务必须从 `inject` 里删掉**——拆掉子代理门之后本插件不再读 `ctx.agents`,`'agents'` 已从 `inject` 移除)。合并调用走 `lib/llm.js` 与 Reflector **同一条**模型路径,不许新开连接方式; 6. `lib/trajectory.js` 必须保持零 import(连 `node:fs` 都不许):宿主包在 `node --test` 里加载不到,本仓库无 `node_modules`;纯函数、无副作用、不读盘。tests/trajectory.test.mjs 有源码级用例守着这条。**`lib/notice.js` 同受这条约束**(零 import、不读盘),由 tests/notice.test.mjs 的 T226 守着。第 33 轮新增的 `lib/timestamp.js` 也按同一口径写成零 import; 7. **归档条目永不物理删除**:被取代(`superseded`)/ 显式移除(`removed-by-request`)/ 最少使用淘汰(`evicted`)/ 合并(`merged`)四种原因一律只是搬进独立归档文件 `archive.md`(`lib/archive-file.js` 的 `appendArchivedEntries`;搬移指令由 `lib/commit.js` 的 `options.archive` / `options.evict` / `options.merged` 三个钩子在锁内产生),两个文件里的条目总数只增不减。`maxEntries` 只数活跃条目;ID 高水位必须把**活跃 + 归档两个文件**的最大编号都算进去(`maxEntryIdNumber(entries)` 与 `maxArchivedIdNumber(archives)`),否则手写手册(没有 `ace:id-highwater` 标记行)下新条目会复用刚归档的编号;且**必须在「满额淘汰」之后才重算**(`lib/commit.js`)。设置页对归档条目只读展示,不提供删除入口; 8. **注入只许追加进历史,绝不许改写系统提示**(第 28 轮):注入块只能由 `lib/inject-delivery.js` 在 `agent/pre-step` 里作为一条 user 消息追加到 `decision.messages` **末尾**(spread 保留 `startsRequestSeries` 等字段)。**不许**把它塞回 `lib/prompt-section.js` 的 section 文本——系统提示在请求最前面,而本插件跑的路由(`magpie/group/never-stuck`)没有声明 `systemPromptUpdate:'in-history'`,宿主只能原地重写首个系统节点,每换一批经验就从注入点(系统提示 25.0% 处)往后全部重算缓存前缀:实测 cached 恒 8,320 tok、单笔重付 87.1K/88.4K/73.4K/97.8K token、73 次切换累计重付 926.0K token。同理**不许**让 section 文本随运行状态变化(inbox 计数句已因此删除)。**系统提示 section 回调绝不抛错**:`playbookSectionText` 现在只有逐字常量拼接,没有 try/catch 需求;注入侧的失败安全在 `createInjectionDelivery().onPreStep` 的 try/catch(只 warn + `injectedFailed + 1`,原样返回 decision),**绝不许**让异常冒到宿主——pre-step 监听器抛错会打哑整回合; 9. **可观测只走内存 + 一行日志**:设置页那六个数字——注入批次 / 注入追加 / **检索命中**(只有 `ace_playbook` 带 query 或 id 的定向查找计入,整本读取不计数)/ **淘汰数** / **合并数** / 归档数(按 `superseded` / `removed-by-request` / `evicted` / `merged` 四原因分列)——全部是进程内状态(宿主重启即归零),**不许为此新增落盘文件**;新增计数点必须同时补一条 `dsh-ace: …` 前缀的 `ctx.logger` 日志,且日志里的数字要和 `GET /api/dsh-ace/stats` 的 `runtime`(七键)对得上。**计分变动、复盘次数这类旧计数器已随评分层删除,不许以任何形式加回来**; 10. **命中账本的取走点唯一 = 回合边界**(第 33 轮口径):`ledger.take` 只在 `agent/turn-stopping` 的 `onTurnStopping` 里调(`lib/recap.js`,**无条件**——冷却回合、阈值未到的回合也要取走并落盘,否则 A 回合的注入会被记成 B 回合的结果),取走之后立刻 `flushHits` 一次原子写盘(`commitMeta` + `markHits`,锁 + 原子写)。复盘流水线(`lib/recap-pipeline.js`)既不取账本也不结算,冻结的时序机制(`settling` / `settlingIds` / `usages` / `pendingUsageIds` / `settlePendingUsage`)已整层删除,**不许以「先结算再复盘」为由加回来**; 11. **淘汰顺序不许改成随机的**(第 33 轮):`lib/evict.js` 的 `compareForEviction` 只有三级判据——`hits` 升序 → `created` 更早先走(`compareStamps`)→ `idNumber` 数字升序。禁止随机、禁止只按时间、禁止引入任何分数或权重;同分同秒时必须由 id 兜底,排序必须是确定的(`tests/archive.test.mjs` 的守护用例①与 `tests/recap-usage.test.mjs` 的跨进程排序用例守着); 12. **合并失败绝不许阻塞落盘**(第 33 轮):满额时先试合并(`lib/merge-llm.js` 的 `planMerges`,最多 5 轮、每轮一对),**某对失败就摘掉换下一对,全部失败就直接淘汰腾位**——任何一步失败都不许整批拒绝候选、不许让复盘失败、不许让落盘停摆。历史教训:一轮 LLM 抛错就把整晚的复盘全丢掉。`planMerges` 因此**永不抛错**,调用方仍要保留自己的 try/catch(双层兜底,见 `tests/recap-pipeline.test.mjs` 的守护用例②); 13. **归档只许进独立文件、不许进任何会话**(第 33 轮):归档条目只存在于 `/ace/archive.md`(与手册同目录,路径由 `lib/playbook-path.js` 的 `resolveArchiveFile` / `resolveSharedArchiveFile` 解析,**不许写死绝对路径**)。注入不读它、`ace_playbook` 整本读取**不再附归档区**、系统提示段不许提它;手册文件里**永远不再出现 `## 归档区` 章节**(`lib/entry-format.js` 的 `parseEntryDocument` 不认这个章节,`serializePlaybook` 不写它)。人可以在设置页看到它,也可以直接手改它; 14. **注入排序键 = 0.9 × 相关度 + 0.1 × 归一化话题热度**(第 36 轮;第 33 轮起不再有净分项):`lib/inject.js` 的 `rankInjection` 只在**已经 `score > 0`(有字面交集)**的候选之间重排,同分按 `idNumber` 数字升序;**热度只许由用户消息产生**(`lib/heat.js` 的 `computeHeat` 内部调 `rankInjection` 时绝不传 heat),把注入出去的条目喂回 `computeHeat` 必须一个计数都不变;**热度绝不许把 `score <= 0` 的条目顶进候选**(那等于取消「本回合相关」这个前提);**`hits` 高低绝不许影响排序**——否则「命中多 → 排得更前 → 更容易再命中」的自增强棘轮会把手册锁死在少数几条上。出局线 `INJECT_NET_FLOOR` 与考察位(`PROBATION_*`)已随评分层删除,不许复活; 15. **候选过滤只剩一条:标题字符数 > 0**(第 33 轮):空标题拒绝,非空一律放行——不再有长度上限、不再有指代词规则、不再有文件名规则,三层闸门(原 `lib/gate.js` 231 行)已整层删除且不许重建。判据只许收紧「不可复用」的那一端,误杀好经验是不可逆损失; 16. **时间戳是毫秒精度的本地 ISO**(第 33 轮):`created` / `updated` / `lastHit` 三字段一律 `YYYY-MM-DDTHH:mm:ss.SSS±HH:MM`(`lib/timestamp.js` 的 `nowStamp`,字典序即时间序);**排序必须用这个精度**,界面上仍只显示到日。旧格式(只有 `YYYY-MM-DD`)必须照常解析、不许抛错,同毫秒并列由 id 数字兜底。元数据字段顺序定死 `created → updated → hits → lastHit`(可选 `auto=true` 恒排最后),序列化恒写四字段。 17. **热度 / 保护期类偏好只许改排序与淘汰顺序,不许绕过相关度与容量硬约束**(第 36 轮):它们能动的只有两处——注入排序键里那 0.1 的权重(且只在有字面交集的候选之间重排,`score <= 0` 一条都进不来)与淘汰时「先取哪一组」(受保护组不够 `need` 时必须能补齐,绝不许死锁、绝不许整批拒绝)。**不许**用热度放宽入候选门槛、**不许**用保护期把「要腾出的 N 位」变小或让容量校验放行、**不许**把热度写进 `hits` 或落盘(窗口是纯内存状态,宿主重启即清空,见红线 9)。 18. **不满额时的合并必须是「更像才动手」的轻量档,绝不许借用满额口径**(第 37 轮):满额(`projected > maxEntries`)继续用 `MERGE_THRESHOLD_RATIO = 0.5` + `MERGE_MAX_ROUNDS = 5`,**这两处一字不许改**;不满额路径只在「`projected <= maxEntries` 且本轮 `adds > 0`」时跑一次轻量合并,阈值 `MERGE_EAGER_THRESHOLD_RATIO = 0.75`、最多 `MERGE_EAGER_MAX_ROUNDS = 2` 对。**不许把两个档位合并成一个常量、不许给 0 候选分支加轻量合并、不许在 `adds === 0` 的回合调 LLM**。落盘通道必须与满额完全相同(`commitOps` 的 `merged` + `ops`,归档原因恒为 `merged`,先写 `archive.md` 再写手册),失败只 warn、绝不阻塞落盘(红线 12)。理由:合并不可逆,被并掉的那条只躺在归档里再也进不了注入;满额是「非合不可」,不满额只是「顺手清理」,收益远小于误合并的代价。机检:tests/recap-pipeline.test.mjs 的 5 条 `e|` 用例 + 变异自证(阈值改回 0.5 → 只红「阈值必须是 0.75」那条)。 19. **注入块首行「最多 N 条」必须说真话**(第 37 轮):N 由生效配置 `injectMaxEntries` 经 `selectInjection` 的 `options.maxEntries` 透传给 `renderInjection`,未传 / `0` / 非法才回落 `INJECT_MAX_ENTRIES`。**只许改文案、不许顺手改取几条**(取几条仍由既有的条数上限 ∩ token 预算决定)。机检:tests/inject.test.mjs 的 `C5.6`、tests/tuning-behaviors.test.mjs 配置行为② 端到端断言。